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/18 18:31:29 UTC
(juneau) 01/08: Add juneau-rest-server-views module and raw-text sidecar serialization
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 4b56621c3e727cd542b6152faf06c2c288981333
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Aug 18 09:07:30 2026 -0400
Add juneau-rest-server-views module and raw-text sidecar serialization
Introduces juneau-rest-server-views, a declarative rich-DataTables view
toolkit (ViewDef/Column/RibbonAction/RowClassRule, ViewTable/PageTable
emitters, ViewsMixin asset serving, and the juneau-views client runtime),
registered in the juneau-rest reactor and the BOM.
The raw-text serialization support is committed alongside it rather than
separately because ViewTable and PageTable emit their VIEW_META/PAGE_META
sidecars via HtmlBuilder.rawText(String); without RawText/RawContent and
the XML/HTML session changes the module does not compile.
NOTICE gains the Material Symbols Outlined attribution for the icon glyphs
bundled in juneau-icons.js.
---
NOTICE | 4 +
.../org/apache/juneau/bean/html5/HtmlBuilder.java | 19 +
.../java/org/apache/juneau/bean/html5/RawText.java | 69 +++
.../bean/html5/HtmlBuilder_RawText_Test.java | 93 +++
.../bean/html5/HtmlElementContainer_Nil_Test.java | 50 ++
juneau-bom/pom.xml | 1 +
.../marshall/html/HtmlSerializerSession.java | 8 +-
.../org/apache/juneau/marshall/xml/RawContent.java | 47 ++
.../apache/juneau/marshall/xml/XmlBeanMeta.java | 3 +-
.../org/apache/juneau/marshall/xml/XmlFormat.java | 25 +
.../juneau/marshall/xml/XmlParserSession.java | 7 +-
.../juneau/marshall/xml/XmlSerializerSession.java | 30 +-
.../juneau/marshall/xml/Xml_RawText_Test.java | 135 +++++
juneau-rest/juneau-rest-server-views/pom.xml | 122 ++++
.../apache/juneau/rest/server/views/Column.java | 170 ++++++
.../apache/juneau/rest/server/views/PageDef.java | 157 +++++
.../apache/juneau/rest/server/views/PageTable.java | 246 ++++++++
.../apache/juneau/rest/server/views/Render.java | 98 ++++
.../juneau/rest/server/views/RibbonAction.java | 451 ++++++++++++++
.../juneau/rest/server/views/RowClassRule.java | 140 +++++
.../apache/juneau/rest/server/views/Subtab.java | 78 +++
.../org/apache/juneau/rest/server/views/Tab.java | 118 ++++
.../apache/juneau/rest/server/views/ViewDef.java | 359 ++++++++++++
.../apache/juneau/rest/server/views/ViewTable.java | 170 ++++++
.../juneau/rest/server/views/ViewsMixin.java | 304 ++++++++++
.../juneau/rest/server/views/package-info.java | 43 ++
.../org/apache/juneau/views/juneau-icons.js | 71 +++
.../org/apache/juneau/views/juneau-pages.js | 236 ++++++++
.../org/apache/juneau/views/juneau-renders.js | 240 ++++++++
.../org/apache/juneau/views/juneau-ribbon.js | 354 +++++++++++
.../org/apache/juneau/views/juneau-views.css | 359 ++++++++++++
.../org/apache/juneau/views/juneau-views.js | 651 +++++++++++++++++++++
.../rest/server/views/JuneauPagesJs_Test.java | 166 ++++++
.../rest/server/views/PageDef_Contract_Test.java | 165 ++++++
.../rest/server/views/PageDef_Validation_Test.java | 160 +++++
.../rest/server/views/PageServerWiring_Test.java | 139 +++++
.../rest/server/views/PageTable_Emit_Test.java | 208 +++++++
.../rest/server/views/PagingPill_Wiring_Test.java | 302 ++++++++++
.../server/views/RibbonIcons_Resolution_Test.java | 72 +++
.../rest/server/views/ViewMeta_Contract_Test.java | 342 +++++++++++
.../rest/server/views/ViewServerWiring_Test.java | 280 +++++++++
.../rest/server/views/ViewTable_Emit_Test.java | 208 +++++++
.../rest/server/views/ViewsJs_PageSeam_Test.java | 64 ++
.../rest/server/views/ViewsMixin_Pages_Test.java | 76 +++
.../rest/server/views/ViewsMixin_Serving_Test.java | 530 +++++++++++++++++
juneau-rest/pom.xml | 1 +
46 files changed, 7559 insertions(+), 12 deletions(-)
diff --git a/NOTICE b/NOTICE
index 7614817a41..f0d0d61a2f 100644
--- a/NOTICE
+++ b/NOTICE
@@ -3,3 +3,7 @@ Copyright 2016-2025 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
+
+This product bundles icon glyphs from the Material Symbols Outlined icon set
+(https://github.com/google/material-design-icons), licensed under the
+Apache License, Version 2.0.
diff --git a/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/HtmlBuilder.java b/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/HtmlBuilder.java
index 86d068690e..2b3e909f9f 100644
--- a/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/HtmlBuilder.java
+++ b/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/HtmlBuilder.java
@@ -1684,6 +1684,25 @@ public class HtmlBuilder {
return new Samp(children);
}
+ /**
+ * Creates a re-serializable, {@link String}-backed {@link RawText} raw-content wrapper.
+ *
+ * <p>
+ * The value is serialized <b>verbatim</b> (no XML entity escaping, no <c>_x####_</c> whitespace encoding, no
+ * wrapping element) by both the XML and HTML serializers, mirroring the one-shot {@link java.io.Reader} raw path but
+ * usable any number of times. Typically used to embed pre-built script/style bodies:
+ *
+ * <p class='bjava'>
+ * Script <jv>s</jv> = <jsm>script</jsm>(<js>"application/json"</js>).text(<jsm>rawText</jsm>(<jv>escapedJson</jv>));
+ * </p>
+ *
+ * @param value The raw, verbatim text content. Can be <jk>null</jk> (nothing is emitted).
+ * @return The new {@link RawText} wrapper.
+ */
+ public static final RawText rawText(String value) {
+ return new RawText(value);
+ }
+
/**
* Creates an empty {@link Script} element.
*
diff --git a/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/RawText.java b/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/RawText.java
new file mode 100644
index 0000000000..b13b1c1fed
--- /dev/null
+++ b/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/RawText.java
@@ -0,0 +1,69 @@
+/*
+ * 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.bean.html5;
+
+import org.apache.juneau.marshall.xml.*;
+
+/**
+ * A re-serializable, {@link String}-backed raw-content wrapper that the XML and HTML serializers emit <b>verbatim</b>.
+ *
+ * <p>
+ * This is the reusable analogue of handing the serializer a one-shot {@link java.io.Reader}: the value is emitted
+ * without any XML entity escaping or <c>_x####_</c> whitespace encoding, and without a wrapping element — but
+ * because it is backed by a {@code String} it can be serialized any number of times. This makes a bean carrying a
+ * {@code RawText} value survive a serialize→object→serialize cycle (for the serialize direction), unlike a
+ * consumed {@code Reader}.
+ *
+ * <p>
+ * Typical use is embedding pre-built script/style bodies into a {@link Script}/{@link Style} element:
+ *
+ * <h5 class='figure'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Browser-safe raw JSON sidecar ('<' pre-escaped to \\u003c by the caller).</jc>
+ * Script <jv>s</jv> = <jsm>script</jsm>(<js>"application/json"</js>).text(<jsm>rawText</jsm>(<jv>escapedJson</jv>));
+ * </p>
+ *
+ * <p>
+ * <b>Safety:</b> content is emitted verbatim. Because Juneau parses via a StAX
+ * {@link javax.xml.stream.XMLStreamReader}, a value containing a raw <js>'<'</js> (e.g. a literal
+ * <c></script></c> sequence) or a bare <js>'&'</js> is browser-correct on serialize but will NOT round-trip
+ * back through Juneau's parser. Callers embedding markup-bearing content are responsible for escaping <js>'<'</js>
+ * to the JS/CSS unicode escape <js>"\\u003c"</js> before wrapping.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jm'>{@link HtmlBuilder#rawText(String)}
+ * <li class='jic'>{@link RawContent}
+ * </ul>
+ */
+public class RawText implements RawContent {
+
+ private final String text;
+
+ /**
+ * Constructor.
+ *
+ * @param text The raw, verbatim text content. Can be <jk>null</jk> (nothing is emitted).
+ */
+ public RawText(String text) {
+ this.text = text;
+ }
+
+ @Override /* Overridden from RawContent */
+ public String toString() {
+ return text;
+ }
+}
diff --git a/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
new file mode 100644
index 0000000000..06524220d0
--- /dev/null
+++ b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
@@ -0,0 +1,93 @@
+/*
+ * 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.bean.html5;
+
+import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.html.*;
+import org.apache.juneau.marshall.xml.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the re-serializable, String-backed {@link HtmlBuilder#rawText(String)} affordance.
+ *
+ * <p>
+ * Unlike a one-shot {@code Reader}, a {@link RawText} is backed by a reusable {@code String} and therefore survives
+ * repeated serialization. Its content is emitted <b>verbatim</b> (no entity/whitespace encoding), mirroring the
+ * existing raw {@code Reader}/{@code InputStream} path.
+ */
+class HtmlBuilder_RawText_Test extends TestBase {
+
+ // A rawText body containing '<' and '&' is emitted verbatim, AND the element re-serializes identically
+ // (proves it is not one-shot like a Reader).
+ @Test void a01_reSerializable() throws Exception {
+ var s = HtmlSerializer.DEFAULT_SQ;
+ var e = script().text(rawText("a<b & c"));
+ var r1 = s.write(e);
+ var r2 = s.write(e);
+ assertEquals("<script>a<b & c</script>", r1);
+ assertEquals(r1, r2);
+ }
+
+ // rawText(s) produces output identical to the established one-shot Reader raw path for the same input.
+ @Test void a02_vsReaderParity() throws Exception {
+ var s = HtmlSerializer.DEFAULT_SQ;
+ var body = "if (a < b) { alert('x & y'); }";
+ var rawTextOut = s.write(script().text(rawText(body)));
+ var readerOut = s.write(script().text(new StringReader(body)));
+ assertEquals(readerOut, rawTextOut);
+ assertEquals("<script>if (a < b) { alert('x & y'); }</script>", rawTextOut);
+ }
+
+ // Also emits verbatim through the XML serializer (RawText is recognized by both).
+ @Test void a03_xmlSerializerVerbatim() throws Exception {
+ var s = XmlSerializer.DEFAULT_SQ;
+ var r = s.write(script().text(rawText("x < y & z")));
+ assertEquals("<script>x < y & z</script>", r);
+ }
+
+ // SECURITY: RAWTEXT/rawText is verbatim by contract -- it does NOT silently neutralize a '</script>'
+ // end-tag sequence in the body. Safety is the caller's responsibility (escape '<' -> \u003c), exactly as
+ // juneau-rest-server-views' ViewTable.escapeForScript does. This test documents that contract.
+ @Test void c01_scriptEndTagIsCallerResponsibility() throws Exception {
+ var s = HtmlSerializer.DEFAULT_SQ;
+
+ // Verbatim: a literal </script> in the body is emitted as-is (would prematurely close the element in a browser).
+ var unsafe = s.write(script().text(rawText("var x = '</script>';")));
+ assertEquals("<script>var x = '</script>';</script>", unsafe);
+ assertEquals(2, countOccurrences(unsafe, "</script>"));
+
+ // Caller neutralizes by escaping '<' -> \u003c (valid JS); the only real </script> is the true close tag.
+ var safe = s.write(script().text(rawText("var x = '\\u003c/script>';")));
+ assertEquals("<script>var x = '\\u003c/script>';</script>", safe);
+ assertEquals(1, countOccurrences(safe, "</script>"));
+ }
+
+ private static int countOccurrences(String haystack, String needle) {
+ var count = 0;
+ var i = 0;
+ while ((i = haystack.indexOf(needle, i)) != -1) {
+ count++;
+ i += needle.length();
+ }
+ return count;
+ }
+}
diff --git a/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlElementContainer_Nil_Test.java b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlElementContainer_Nil_Test.java
new file mode 100644
index 0000000000..725878b30e
--- /dev/null
+++ b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlElementContainer_Nil_Test.java
@@ -0,0 +1,50 @@
+/*
+ * 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.bean.html5;
+
+import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.html.*;
+import org.apache.juneau.marshall.xml.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that empty HTML container/mixed elements do not serialize a stray <c>nil='true'</c> attribute
+ * when using serializers that add JSON type tags (the default).
+ */
+class HtmlElementContainer_Nil_Test extends TestBase {
+
+ @Test void a01_emptyContainer_html_noNil() {
+ assertEquals("<table></table>", HtmlSerializer.DEFAULT_SQ.toString(table()));
+ }
+
+ @Test void a02_emptyContainer_xml_noNil() {
+ assertEquals("<table></table>", XmlSerializer.DEFAULT_SQ.toString(table()));
+ }
+
+ @Test void a03_nestedEmptyContainers_html_noNil() {
+ assertEquals("<table><thead></thead></table>", HtmlSerializer.DEFAULT_SQ.toString(table().child(thead())));
+ }
+
+ @Test void a04_emptyContainers_variety_html_noNil() {
+ assertEquals("<tbody></tbody>", HtmlSerializer.DEFAULT_SQ.toString(tbody()));
+ assertEquals("<tr></tr>", HtmlSerializer.DEFAULT_SQ.toString(tr()));
+ assertEquals("<ul></ul>", HtmlSerializer.DEFAULT_SQ.toString(ul()));
+ }
+}
diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index 760628be8d..64e3373b2b 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -62,6 +62,7 @@
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-tracing-otel</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-management-logging</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-datatables</artifactId><version>${project.version}</version></dependency>
+ <dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-views</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker-datatables</artifactId><version>${project.version}</version></dependency>
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
index bac1a8e089..1f69b46634 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
@@ -920,14 +920,16 @@ public class HtmlSerializerSession extends XmlSerializerSession {
sType = getClassMetaForObject(o);
}
- // Handle the case where we're serializing a raw stream.
- if (sType.isReader() || sType.isInputStream()) {
+ // Handle the case where we're serializing a raw stream or re-serializable raw content.
+ if (sType.isReader() || sType.isInputStream() || o instanceof RawContent) {
pop();
indent -= xIndent;
if (sType.isReader())
pipe((Reader)o, out, SerializerSession::handleThrown);
- else
+ else if (sType.isInputStream())
pipe((InputStream)o, out, SerializerSession::handleThrown);
+ else
+ out.append(o.toString());
return ContentResult.CR_MIXED;
}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/RawContent.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/RawContent.java
new file mode 100644
index 0000000000..5f4d0ab827
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/RawContent.java
@@ -0,0 +1,47 @@
+/*
+ * 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.marshall.xml;
+
+/**
+ * Marker interface for objects whose {@link #toString()} value should be emitted <b>verbatim</b> as raw element
+ * content by the XML and HTML serializers.
+ *
+ * <p>
+ * This is the re-serializable analogue of the raw {@link java.io.Reader}/{@link java.io.InputStream} path: the value is
+ * appended as-is (no XML entity escaping, no <c>_x####_</c> whitespace/invalid-char encoding, no wrapping element), but
+ * because it is backed by a reusable value (typically a {@code String}) it survives repeated serialization —
+ * unlike a one-shot {@code Reader} which is consumed on first use.
+ *
+ * <p>
+ * Implementations are responsible for producing content that is safe for the target syntax. Notably, because Juneau
+ * parses via a StAX {@link javax.xml.stream.XMLStreamReader}, content containing a raw <js>'<'</js> or a bare
+ * <js>'&'</js> will serialize correctly (browser-safe) but will NOT round-trip back through Juneau's parser.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jf'>{@link XmlFormat#RAWTEXT}
+ * </ul>
+ */
+public interface RawContent {
+
+ /**
+ * Returns the raw, verbatim character content to emit.
+ *
+ * @return The raw content. May be <jk>null</jk> (nothing is emitted).
+ */
+ @Override
+ String toString();
+}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlBeanMeta.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlBeanMeta.java
index 908f17d861..a7f3084102 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlBeanMeta.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlBeanMeta.java
@@ -87,7 +87,7 @@ public class XmlBeanMeta extends ExtendedBeanMeta {
if (! pcm.isMapOrBean())
throw brex(c, "Invalid type for ATTRS property. Only properties of type Map and bean can be used.");
attrsProperty = p;
- } else if (xf.isOneOf(FREEFORM, MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT)) {
+ } else if (xf.isOneOf(FREEFORM, MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT)) {
if (xf.isOneOf(FREEFORM, MIXED, MIXED_PWS) && ! pcm.isCollectionOrArray())
throw brex(c, "Invalid type for %s property. Only properties of type Collection and array can be used.", xf);
if (nn(contentProperty)) {
@@ -173,6 +173,7 @@ public class XmlBeanMeta extends ExtendedBeanMeta {
* <li>{@link XmlFormat#TEXT}
* <li>{@link XmlFormat#TEXT_PWS}
* <li>{@link XmlFormat#XMLTEXT}
+ * <li>{@link XmlFormat#RAWTEXT}
* <li>{@link XmlFormat#VOID}
* <li><jk>null</jk>
* </ul>
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlFormat.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlFormat.java
index d918d709bd..bb4bed3426 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlFormat.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlFormat.java
@@ -144,6 +144,31 @@ public enum XmlFormat {
*/
XMLTEXT,
+ /**
+ * Render property value as the raw, verbatim text content of the element.
+ *
+ * <p>
+ * Similar to {@link #TEXT_PWS} (single, whitespace-preserving value), but the value is emitted with NO XML entity
+ * escaping (no <c>&amp;</c>/<c>&lt;</c>/<c>&gt;</c> substitution) and NO <c>_x####_</c>
+ * whitespace/invalid-char encoding.
+ * This matches HTML5 "raw text" element semantics (<xt><script></xt>/<xt><style></xt>) where browsers do
+ * not decode character references.
+ *
+ * <p>
+ * Unlike {@link #XMLTEXT}, the content is NOT re-parsed as XML on parse; it is read back verbatim as a text node
+ * (no <c>_x####_</c> decoding, no trimming).
+ *
+ * <p>
+ * Only applicable for scalar (non-collection) bean properties.
+ *
+ * <p>
+ * <b>Round-trip caveat:</b> because Juneau parses via a StAX {@link javax.xml.stream.XMLStreamReader}, a value
+ * containing a raw <js>'<'</js> or a bare <js>'&'</js> will NOT be parseable back.
+ * Callers embedding markup-bearing content are responsible for escaping <js>'<'</js> (e.g. to the JS/CSS unicode
+ * escape <js>"\\u003c"</js>) before use, exactly as required for browser-safe raw-text content.
+ */
+ RAWTEXT,
+
/**
* Prevents collections and arrays from being enclosed in <xt><array></xt> elements.
*
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlParserSession.java
index 3530d13668..715653d2c2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlParserSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlParserSession.java
@@ -389,7 +389,7 @@ public class XmlParserSession extends ReaderParserSession implements RecordReada
var cp = xmlMeta.getContentProperty();
var cpf = xmlMeta.getContentFormat();
- var trim = cp == null || ! cpf.isOneOf(MIXED_PWS, TEXT_PWS);
+ var trim = cp == null || ! cpf.isOneOf(MIXED_PWS, TEXT_PWS, RAWTEXT);
ClassMeta<?> cpcm = (cp == null ? object() : (ClassMeta<?>) cp.getBeanInfo());
StringBuilder sb = null;
var breg = cp == null ? null : cp.getBeanRegistry();
@@ -410,6 +410,11 @@ public class XmlParserSession extends ReaderParserSession implements RecordReada
} else {
cp.set(m, null, getText(r, trim));
}
+ } else if (nn(cp) && cpf == RAWTEXT) {
+ // Verbatim: no trim, no _x####_ decoding (mirrors RAWTEXT serialize which bypasses encoding).
+ if (sb == null)
+ sb = getStringBuilder();
+ sb.append(r.getText());
} else if (cpf != FREEFORM) {
var s = getText(r, trim);
if (nn(s)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
index ec8b1371fe..bdcd72fda2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
@@ -383,6 +383,15 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
return xcm.getFormat() == XMLTEXT;
}
+ private boolean isRawText(XmlFormat format, ClassMeta<?> sType) {
+ if (format == RAWTEXT)
+ return true;
+ var xcm = getXmlClassMeta(sType);
+ if (xcm == null)
+ return false;
+ return xcm.getFormat() == RAWTEXT;
+ }
+
private Optional<Map.Entry<String,Object>> getPropertyKeyValueIfNotIgnored(BeanPropertyValue p, BeanPropertyMeta pMeta) {
var key = p.getName();
var value = p.getValue();
@@ -470,7 +479,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
contentType = (ClassMeta<?>) p.getBeanInfo();
hasContent = true;
cf = xbm.getContentFormat();
- if (cf.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT))
+ if (cf.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT))
isMixedOrText = true;
if (cf.isOneOf(MIXED_PWS, TEXT_PWS))
preserveWhitespace = true;
@@ -535,7 +544,11 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
writeAnything(out, content, contentType, null, null, null, false, cf, isMixedOrText, preserveWhitespace, null);
}
} else {
- if (isAddJsonTags())
+ // A null FREEFORM content property represents an empty container element (e.g. an empty HTML <table> or
+ // <thead>), which must render as an empty element rather than an explicit null. Emitting nil='true' here
+ // is surprising for such containers. Other content formats (TEXT/TEXT_PWS/XMLTEXT for null-vs-empty-string
+ // round-tripping, and MIXED/MIXED_PWS) retain the nil marker to preserve existing behavior.
+ if (isAddJsonTags() && xbm.getContentFormat() != FREEFORM)
out.attr("nil", "true");
out.w('>').nlIf(! isMixedOrText, indent);
}
@@ -926,7 +939,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
o = null;
boolean isCollapsed = false; // If 'true', this is a collection and we're not rendering the outer element.
- boolean isRaw = (sType.isReader() || sType.isInputStream()) && nn(o);
+ boolean isRaw = (sType.isReader() || sType.isInputStream() || o instanceof RawContent) && nn(o);
// Get the JSON type string.
if (o == null) {
@@ -947,7 +960,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
type = STRING;
}
- if (format.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT) && type.isOneOf(NULL, STRING, NUMBER, BOOLEAN))
+ if (format.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT) && type.isOneOf(NULL, STRING, NUMBER, BOOLEAN))
isCollapsed = true;
// Is there a name associated with this bean?
@@ -1032,7 +1045,10 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
// Render the tag contents.
if (nn(o)) {
- if (sType.isBean()) {
+ if (o instanceof RawContent) {
+ // Re-serializable, String-backed raw content: emit verbatim (no entity/whitespace encoding), like the Reader path.
+ out.append(o.toString());
+ } else if (sType.isBean()) {
rc = writeBeanMap(out, toBeanMap(o), elementNamespace, isCollapsed, isMixedOrText);
} else if (sType.isMap() || (nn(wType) && wType.isMap())) {
if (o instanceof BeanMap o2)
@@ -1049,7 +1065,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
// Must come before isCharSequence/isChar: @Uri-annotated String properties (where sType is String but pMeta.isUri() is true) need to route here for URI text emission.
out.textUri(o);
} else if (sType.isCharSequence() || sType.isChar()) {
- if (isXmlText(format, sType))
+ if (isXmlText(format, sType) || isRawText(format, sType))
out.append(o);
else
out.text(o, preserveWhitespace);
@@ -1076,7 +1092,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
} else if (sType.isInputStream()) {
pipe((InputStream)o, out, SerializerSession::handleThrown);
} else {
- if (isXmlText(format, sType))
+ if (isXmlText(format, sType) || isRawText(format, sType))
out.append(toString(o));
else
out.text(toString(o));
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/Xml_RawText_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/Xml_RawText_Test.java
new file mode 100644
index 0000000000..175ee42dee
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/Xml_RawText_Test.java
@@ -0,0 +1,135 @@
+/*
+ * 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.marshall.xml;
+
+import static org.apache.juneau.TestUtils.*;
+import static org.apache.juneau.marshall.xml.XmlFormat.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.*;
+import org.apache.juneau.marshall.parser.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the {@link XmlFormat#RAWTEXT} content format.
+ *
+ * <p>
+ * RAWTEXT emits a scalar String content property <b>verbatim</b> (no {@code &}/{@code <}/{@code >} entity
+ * escaping and no {@code _x####_} whitespace/invalid-char encoding), mirroring HTML5 raw-text element semantics
+ * ({@code <script>}/{@code <style>}). On the parse side it is read back verbatim (no decode, no trim).
+ */
+class Xml_RawText_Test extends TestBase {
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Serialize
+ //-----------------------------------------------------------------------------------------------------------------
+
+ // A body containing raw '<', '>' and '&' must be emitted VERBATIM (no entity escaping).
+ @Test void a01_serializeVerbatim() throws Exception {
+ var s = XmlSerializer.DEFAULT_SQ;
+ var t = new A();
+ t.f1 = "f1";
+ t.f2 = "if (a < b) { return a & b > c; }";
+ assertEquals("<A f1='f1'>if (a < b) { return a & b > c; }</A>", s.write(t));
+ }
+
+ // Whitespace (newlines/tabs) must be emitted literally (contrast with TEXT_PWS's 
/	/_x0020_).
+ @Test void a02_serializeWhitespaceVerbatim() throws Exception {
+ var s = XmlSerializer.DEFAULT_SQ;
+ var t = new A();
+ t.f1 = "f1";
+ t.f2 = "\n\tx();\n";
+ assertEquals("<A f1='f1'>\n\tx();\n</A>", s.write(t));
+ }
+
+ // A literal _x####_ sequence must NOT be encoded on serialize (verbatim).
+ @Test void a03_serializeNoUnderscoreEncoding() throws Exception {
+ var s = XmlSerializer.DEFAULT_SQ;
+ var t = new A();
+ t.f1 = "f1";
+ t.f2 = " _x0041_ leading";
+ assertEquals("<A f1='f1'> _x0041_ leading</A>", s.write(t));
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Parse (round-trip) of StAX-safe content
+ //-----------------------------------------------------------------------------------------------------------------
+
+ // StAX-safe content (no raw '<' or bare '&') round-trips verbatim (no decode, no trim).
+ @Test void b01_roundTripSafeContent() throws Exception {
+ var s = XmlSerializer.DEFAULT_SQ;
+ var p = XmlParser.DEFAULT;
+ var t = new A();
+ t.f1 = "f1";
+ t.f2 = "var x = 1;\n";
+ var r = s.write(t);
+ var t2 = p.read(r, A.class);
+ assertEquals(t.f2, t2.f2);
+ assertEquals(json(t), json(t2));
+ }
+
+ // Caller-escaped markup (ViewTable-style '<' -> \u003c) round-trips byte-identical.
+ @Test void b02_roundTripEscapedMarkup() throws Exception {
+ var s = XmlSerializer.DEFAULT_SQ;
+ var p = XmlParser.DEFAULT;
+ var t = new A();
+ t.f1 = "f1";
+ t.f2 = "{\"a\":\"\\u003cb\\u003e\"}";
+ var r = s.write(t);
+ var t2 = p.read(r, A.class);
+ assertEquals(t.f2, t2.f2);
+ }
+
+ // A literal _x0041_ in RAWTEXT content must NOT be decoded on parse (stays verbatim).
+ @Test void b03_roundTripNoUnderscoreDecode() throws Exception {
+ var s = XmlSerializer.DEFAULT_SQ;
+ var p = XmlParser.DEFAULT;
+ var t = new A();
+ t.f1 = "f1";
+ t.f2 = "value _x0041_ here";
+ var r = s.write(t);
+ var t2 = p.read(r, A.class);
+ assertEquals("value _x0041_ here", t2.f2);
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // LIMITATION: verbatim raw '<' is not round-trippable through Juneau's StAX parser.
+ //-----------------------------------------------------------------------------------------------------------------
+
+ // This test intentionally documents (and locks in) the constraint from the design: RAWTEXT emits raw '<'
+ // verbatim (browser-correct), but Juneau parses via a StAX XMLStreamReader, so the resulting non-well-formed
+ // XML CANNOT be parsed back. Callers embedding markup must escape '<' -> \u003c (see b02).
+ @Test void b04_rawAngleBracketNotParseable() throws Exception {
+ var s = XmlSerializer.DEFAULT_SQ;
+ var p = XmlParser.DEFAULT;
+ var t = new A();
+ t.f1 = "f1";
+ t.f2 = "if (a < b) {}";
+ var r = s.write(t);
+ // The serialized output is verbatim (browser-correct)...
+ assertEquals("<A f1='f1'>if (a < b) {}</A>", r);
+ // ...but is not well-formed XML, so parsing it back fails.
+ assertThrows(ParseException.class, () -> p.read(r, A.class));
+ }
+
+ @Marshalled(typeName="A")
+ public static class A {
+ @Xml(format=ATTR) public String f1;
+ @Xml(format=RAWTEXT) public String f2;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/pom.xml b/juneau-rest/juneau-rest-server-views/pom.xml
new file mode 100644
index 0000000000..e6ac1a3e76
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/pom.xml
@@ -0,0 +1,122 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest</artifactId>
+ <version>10.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>juneau-rest-server-views</artifactId>
+ <name>Apache Juneau REST Server Views</name>
+ <description>Apache Juneau REST Server - Rich DataTables view toolkit: declarative VIEW_META view definitions + client runtime (opt-in)</description>
+ <packaging>bundle</packaging>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-datatables</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Test scope -->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test-utils</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-mock</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>jar-no-fork</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>maven-bundle-plugin</artifactId>
+ <extensions>true</extensions>
+ <configuration>
+ <supportIncrementalBuild>true</supportIncrementalBuild>
+ </configuration>
+ <executions>
+ <execution>
+ <id>bundle-manifest</id>
+ <phase>process-classes</phase>
+ <goals>
+ <goal>manifest</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.jacoco</groupId>
+ <artifactId>jacoco-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>default-prepare-agent</id>
+ <goals>
+ <goal>prepare-agent</goal>
+ </goals>
+ </execution>
+ <execution>
+ <id>default-report</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>report</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/juneau-rest/juneau-rest-server-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
new file mode 100644
index 0000000000..eb28fa822d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Column.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A single column descriptor in the {@code VIEW_META} wire contract (design doc §6.4).
+ *
+ * <p>
+ * Mirrors the DataTables column shape (the DataTables-native terms {@code orderable}/{@code searchable}/
+ * {@code className} are kept) and adds a named-renderer reference ({@link #render}) plus a declarative
+ * {@code {property}} URL template ({@link #href}). Optional fields ({@code name}, {@code href}, {@code className})
+ * are omitted from the wire when unset.
+ *
+ * <p>
+ * The reserved catalog/View-Settings fields ({@code format}, {@code description}, {@code pinned},
+ * {@code defaultVisible}) are <b>not</b> exposed by this MVP builder and are therefore omitted from the serialized
+ * contract (design doc §6.4 reserved stubs).
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ViewDef}
+ * <li class='jc'>{@link Render}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="data,name,title,orderable,searchable,render,href,className")
+public class Column {
+
+ /** The bean-property / JSON key this column reads from each row. */
+ public String data;
+
+ /** Optional stable DataTables column {@code name}; defaults to {@link #data} when omitted. */
+ public String name;
+
+ /** The header text. */
+ public String title;
+
+ /** Whether the column is orderable (DataTables-native term). Defaults to <jk>true</jk>. */
+ public boolean orderable = true;
+
+ /** Whether the column is searchable. Defaults to <jk>true</jk>. */
+ public boolean searchable = true;
+
+ /** The named cell-renderer reference. */
+ public Render render;
+
+ /** Optional declarative <c>{property}</c> URL template consumed by the {@code linked} renderer. */
+ public String href;
+
+ /** Optional static CSS class on the cell (DataTables-native {@code className}). */
+ public String className;
+
+ /**
+ * Creates a column bound to the specified data key, with {@code orderable}/{@code searchable} defaulting to
+ * <jk>true</jk>.
+ *
+ * @param data The bean-property / JSON key. Must not be <jk>null</jk>.
+ * @return A new {@link Column}.
+ */
+ public static Column of(String data) {
+ var c = new Column();
+ c.data = data;
+ return c;
+ }
+
+ /**
+ * Sets the stable DataTables column name.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public Column name(String value) {
+ name = value;
+ return this;
+ }
+
+ /**
+ * Sets the header text.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Column title(String value) {
+ title = value;
+ return this;
+ }
+
+ /**
+ * Sets whether the column is orderable.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Column orderable(boolean value) {
+ orderable = value;
+ return this;
+ }
+
+ /**
+ * Sets whether the column is searchable.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Column searchable(boolean value) {
+ searchable = value;
+ return this;
+ }
+
+ /**
+ * Sets the named cell-renderer using the <c>"id:field"</c> string sugar (see {@link Render#parse(String)}).
+ *
+ * @param value The render-id string. E.g. <c>"tag:status"</c> or <c>"date"</c>.
+ * @return This object.
+ */
+ public Column render(String value) {
+ render = Render.parse(value);
+ return this;
+ }
+
+ /**
+ * Sets the named cell-renderer to a pre-built {@link Render} (canonical form).
+ *
+ * @param value The renderer reference.
+ * @return This object.
+ */
+ public Column render(Render value) {
+ render = value;
+ return this;
+ }
+
+ /**
+ * Sets the declarative <c>{property}</c> URL template.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public Column href(String value) {
+ href = value;
+ return this;
+ }
+
+ /**
+ * Sets the static CSS class on the cell.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public Column className(String value) {
+ className = value;
+ return this;
+ }
+}
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
new file mode 100644
index 0000000000..b923d7a255
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.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.
+ *
+ * <p>
+ * A {@link PageDef} is an ordinary Juneau bean built via a small fluent builder ({@link #create(String)} + chained
+ * setters + {@link #build()}), mirroring {@link ViewDef}'s builder ergonomics exactly. It holds an ordered list of
+ * {@link Tab}; each {@link Tab} holds either a single child {@link ViewDef} reference or an ordered list of
+ * {@link Subtab}, each of which references one {@link ViewDef}.
+ *
+ * <p>
+ * The page does <b>not</b> wrap/absorb a child view's configuration — {@link PageTable} renders each
+ * referenced view exactly as {@code ViewTable.of(...)} already does (marker table + VIEW_META sidecar) and only
+ * controls which panel is visible. {@link #contractVersion} reuses {@link ViewDef#CONTRACT_VERSION} as the single
+ * source of truth, mirroring {@code ViewsMixin.CONTRACT_VERSION}.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * PageDef <jv>page</jv> = PageDef.<jsm>create</jsm>(<js>"admin"</js>)
+ * .title(<js>"Admin"</js>)
+ * .tabs(
+ * Tab.<jsm>create</jsm>(<js>"releases"</js>, <js>"Releases"</js>).view(releasesView),
+ * Tab.<jsm>create</jsm>(<js>"catalog"</js>, <js>"Catalog"</js>).subtabs(
+ * Subtab.<jsm>create</jsm>(<js>"packages"</js>, <js>"Packages"</js>).view(packagesView),
+ * Subtab.<jsm>create</jsm>(<js>"bundles"</js>, <js>"Bundles"</js>).view(bundlesView)))
+ * .build();
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link Tab}
+ * <li class='jc'>{@link Subtab}
+ * <li class='jc'>{@link PageTable}
+ * <li class='jc'>{@link ViewDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="contractVersion,id,title,tabs")
+public class PageDef {
+
+ /** The frozen contract version, reusing {@link ViewDef#CONTRACT_VERSION} as the single source of truth. */
+ public static final String CONTRACT_VERSION = ViewDef.CONTRACT_VERSION;
+
+ /** The frozen contract-version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
+ public String contractVersion = CONTRACT_VERSION;
+
+ /** The stable page id (the first hash segment, {@code #<id>/tabId[/subtabId]}). */
+ public String id;
+
+ /** Optional page title. */
+ public String title;
+
+ /** The ordered top-level tabs. */
+ public List<Tab> tabs;
+
+ /**
+ * Starts a new {@link PageDef} builder with the specified stable page id.
+ *
+ * @param id The stable page id. Must not be <jk>null</jk> or blank.
+ * @return A new mutable {@link PageDef} to chain builder calls on.
+ */
+ public static PageDef create(String id) {
+ if (id == null || id.isBlank())
+ throw iaex("PageDef id must not be null or blank.");
+ var p = new PageDef();
+ p.id = id;
+ return p;
+ }
+
+ /**
+ * Sets the page title.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public PageDef title(String value) {
+ title = value;
+ return this;
+ }
+
+ /**
+ * Sets the ordered top-level tabs.
+ *
+ * @param value The tabs, in display order.
+ * @return This object.
+ */
+ public PageDef tabs(Tab...value) {
+ tabs = new ArrayList<>(Arrays.asList(value));
+ return this;
+ }
+
+ /**
+ * Finalizes the builder, validating the composed tab tree, and returns the wire-ready {@link PageDef}.
+ *
+ * <p>
+ * Validation (design doc §"Bean model"): at least one tab must be declared; every {@link Tab} must declare
+ * exactly one of {@link Tab#view}/{@link Tab#subtabs} (delegated to {@link Tab#validate()}, which also checks
+ * sub-tab id uniqueness <i>within</i> that tab); every {@link Tab#id} must be unique across the page; and every
+ * referenced {@link ViewDef#id} (from a leaf tab's view or any subtab's view) must be unique across the whole
+ * page, so hash routing and sidecar lookup stay unambiguous.
+ *
+ * @return This object.
+ * @throws IllegalArgumentException On any validation rule violation.
+ */
+ public PageDef build() {
+ validate();
+ return this;
+ }
+
+ private void validate() {
+ if (tabs == null || tabs.isEmpty())
+ throw iaex("PageDef '%s' must declare at least one tab.", id);
+ var tabIds = new HashSet<String>();
+ var viewIds = new HashSet<String>();
+ for (var t : tabs) {
+ t.validate();
+ if (!tabIds.add(t.id))
+ throw iaex("PageDef '%s': duplicate tab id '%s'.", id, t.id);
+ if (t.view != null) {
+ addViewId(viewIds, t.view.id);
+ } else {
+ for (var s : t.subtabs)
+ addViewId(viewIds, s.view.id);
+ }
+ }
+ }
+
+ private void addViewId(Set<String> viewIds, String viewId) {
+ if (!viewIds.add(viewId))
+ throw iaex("PageDef '%s': duplicate referenced ViewDef id '%s'.", id, viewId);
+ }
+}
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
new file mode 100644
index 0000000000..42cccce3ac
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.html5.*;
+import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.marshall.*;
+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.
+ *
+ * <p>
+ * Mirrors the sibling {@link ViewTable} emitter pattern exactly, including its escaping contract (see that class's
+ * javadoc): PAGE_META is serialized with the repo's canonical compact JSON marshaller, has every {@code <} escaped
+ * to its JSON unicode escape ({@link ViewTable#escapeForScript(String)} — reused verbatim, not re-implemented,
+ * so the two sidecars can never drift in how they neutralize a {@code </script>} break-out), and is inserted as
+ * {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) raw content} so the returned bean stays
+ * re-serializable.
+ *
+ * <h5 class='section'>No module dependency, class-based chrome only (Decision 1(A)):</h5>
+ * <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.
+ *
+ * <h5 class='section'>Child views stay independent (no wrapping/absorption):</h5>
+ * <p>
+ * Each referenced {@link ViewDef} is rendered by delegating straight to {@link ViewTable#of(MarshallingContext,
+ * ViewDef, Collection) ViewTable.of(ctx, view, null)} — the exact same code path a standalone
+ * {@code ViewTable.of(view)} call uses — so a child view's marker table + VIEW_META sidecar is byte-for-byte
+ * identical whether reached standalone or through a page. This page emitter only wraps that untouched output in a
+ * tab-bar/panel shell and controls which panel is visible; it never re-derives or mutates the child's contract.
+ *
+ * <h5 class='section'>PAGE_META shape:</h5>
+ * <p>
+ * A light-weight tab tree (id/label/subtabs only — no embedded {@code ViewDef} content, since the page
+ * runtime's lazy-init binding layer locates a panel's {@code table[data-juneau-view]} elements directly via the DOM
+ * rather than via PAGE_META): <c>{contractVersion, id, tabs:[{id, label, subtabs?:[{id, label}]}]}</c>.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link PageDef}
+ * <li class='jc'>{@link ViewTable}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class PageTable {
+
+ /** Marker attribute the {@code juneau-pages.js} runtime looks for to auto-initialize a page shell. */
+ public static final String MARKER_ATTR = "data-juneau-page";
+
+ /** Prefix of the PAGE_META sidecar {@code <script>} element id: {@code juneau-page:<pageId>}. */
+ public static final String SIDECAR_ID_PREFIX = "juneau-page:";
+
+ /** Class on the page shell root. */
+ public static final String PAGE_CLASS = "jc-page";
+
+ /** Class on the top-level tab-bar container. */
+ public static final String TAB_BAR_CLASS = "jc-tab-bar";
+
+ /** Class on a single top-level tab-bar link. */
+ public static final String TAB_CLASS = "jc-tab";
+
+ /** Class on a sub-tab-bar container (nested inside a tab's panel). */
+ public static final String SUBTAB_BAR_CLASS = "jc-subtab-bar";
+
+ /** Class on a single sub-tab-bar link. */
+ public static final String SUBTAB_CLASS = "jc-subtab";
+
+ /** Class on a top-level tab panel. */
+ public static final String PANEL_CLASS = "jc-panel";
+
+ /** Class on a sub-tab panel (nested inside a tab's panel). */
+ public static final String SUBPANEL_CLASS = "jc-subpanel";
+
+ private PageTable() {}
+
+ /**
+ * Builds the page shell for a built {@link PageDef}, using the default marshalling context.
+ *
+ * @param pageDef The built page definition. Must not be <jk>null</jk>.
+ * @return A new {@link Div} carrying the {@code [data-juneau-page]} shell, tab/sub-tab bars, one panel per
+ * referenced view, and the PAGE_META sidecar.
+ */
+ public static Div of(PageDef pageDef) {
+ return of(MarshallingContext.DEFAULT, pageDef);
+ }
+
+ /**
+ * Builds the page shell using the specified marshalling context for the wrapped child views' cell reads.
+ *
+ * @param ctx The marshalling context passed through to each child {@code ViewTable.of(...)} call. Must not be
+ * <jk>null</jk>.
+ * @param pageDef The built page definition. Must not be <jk>null</jk>.
+ * @return A new {@link Div} carrying the {@code [data-juneau-page]} shell, tab/sub-tab bars, one panel per
+ * referenced view, and the PAGE_META sidecar.
+ */
+ public static Div of(MarshallingContext ctx, PageDef pageDef) {
+ var id = pageDef.id;
+ var tabs = pageDef.tabs == null ? List.<Tab>of() : pageDef.tabs;
+
+ var tabBarChildren = new ArrayList<>();
+ for (var t : tabs)
+ tabBarChildren.add(
+ a(hashHref(id, t.id, null), t.label == null ? t.id : t.label)
+ .class_(TAB_CLASS)
+ .attr("data-tab-id", t.id));
+ var tabBar = nav(tabBarChildren.toArray()).class_(TAB_BAR_CLASS).attr("role", "tablist");
+
+ var panelsChildren = new ArrayList<>();
+ for (var t : tabs)
+ panelsChildren.add(buildTabPanel(ctx, id, t));
+ var panels = div(panelsChildren.toArray()).class_("jc-panels");
+
+ var json = ViewTable.escapeForScript(Json.of(buildMeta(pageDef)));
+ var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
+
+ return div(tabBar, panels, sidecar).id(id).attr(MARKER_ATTR, id).class_(PAGE_CLASS);
+ }
+
+ /** Builds one top-level tab's panel: either a leaf view panel, or a sub-tab bar + one sub-panel per subtab. */
+ private static Div buildTabPanel(MarshallingContext ctx, String pageId, Tab t) {
+ if (t.view != null) {
+ var body = ViewTable.of(ctx, t.view, null);
+ return div(body).class_(PANEL_CLASS).attr("data-panel-tab", t.id);
+ }
+
+ var subtabs = t.subtabs == null ? List.<Subtab>of() : t.subtabs;
+
+ var subtabBarChildren = new ArrayList<>();
+ for (var s : subtabs)
+ subtabBarChildren.add(
+ a(hashHref(pageId, t.id, s.id), s.label == null ? s.id : s.label)
+ .class_(SUBTAB_CLASS)
+ .attr("data-subtab-id", s.id)
+ .attr("data-parent-tab", t.id));
+ var subtabBar = nav(subtabBarChildren.toArray()).class_(SUBTAB_BAR_CLASS).attr("role", "tablist");
+
+ var subpanelsChildren = new ArrayList<>();
+ for (var s : subtabs) {
+ var body = ViewTable.of(ctx, s.view, null);
+ subpanelsChildren.add(
+ div(body).class_(SUBPANEL_CLASS).attr("data-panel-tab", t.id).attr("data-panel-subtab", s.id));
+ }
+ var subpanels = div(subpanelsChildren.toArray()).class_("jc-subpanels");
+
+ return div(subtabBar, subpanels).class_(PANEL_CLASS).attr("data-panel-tab", t.id);
+ }
+
+ /** Builds the deep-linkable hash href: {@code #pageId/tabId} or {@code #pageId/tabId/subtabId}. */
+ private static String hashHref(String pageId, String tabId, String subtabId) {
+ var sb = new StringBuilder("#").append(pageId).append('/').append(tabId);
+ if (subtabId != null)
+ sb.append('/').append(subtabId);
+ return sb.toString();
+ }
+
+ /** Projects a {@link PageDef} down to the light-weight PAGE_META tab tree (no embedded ViewDef content). */
+ private static PageMeta buildMeta(PageDef pageDef) {
+ var meta = new PageMeta();
+ meta.contractVersion = pageDef.contractVersion;
+ meta.id = pageDef.id;
+ var tabMetas = new ArrayList<TabMeta>();
+ for (var t : (pageDef.tabs == null ? List.<Tab>of() : pageDef.tabs)) {
+ var tm = new TabMeta();
+ tm.id = t.id;
+ tm.label = t.label;
+ if (t.subtabs != null) {
+ var subMetas = new ArrayList<SubtabMeta>();
+ for (var s : t.subtabs) {
+ var sm = new SubtabMeta();
+ sm.id = s.id;
+ sm.label = s.label;
+ subMetas.add(sm);
+ }
+ tm.subtabs = subMetas;
+ }
+ tabMetas.add(tm);
+ }
+ meta.tabs = tabMetas;
+ return meta;
+ }
+
+ /** The PAGE_META root: {@code {contractVersion, id, tabs}}. */
+ @BeanType(properties="contractVersion,id,tabs")
+ public static class PageMeta {
+
+ /** The frozen contract-version discriminator. */
+ public String contractVersion;
+
+ /** The page id. */
+ public String id;
+
+ /** The tab tree. */
+ public List<TabMeta> tabs;
+ }
+
+ /** One PAGE_META tab entry: {@code {id, label, subtabs?}}. */
+ @BeanType(properties="id,label,subtabs")
+ public static class TabMeta {
+
+ /** The tab id. */
+ public String id;
+
+ /** The tab label. */
+ public String label;
+
+ /** The sub-tab entries; omitted for a leaf tab. */
+ public List<SubtabMeta> subtabs;
+ }
+
+ /** One PAGE_META sub-tab entry: {@code {id, label}}. */
+ @BeanType(properties="id,label")
+ public static class SubtabMeta {
+
+ /** The sub-tab id. */
+ public String id;
+
+ /** The sub-tab label. */
+ public String label;
+ }
+}
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
new file mode 100644
index 0000000000..be36683ba6
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A named cell-renderer reference in the {@code VIEW_META} wire contract (design doc §6.6).
+ *
+ * <p>
+ * Serializes to the canonical object form <c>{"id":"tag","meta":{"field":"status"}}</c>. The client
+ * {@code juneau-renders.js} registry looks up {@link #id} and invokes the matching renderer, passing the
+ * {@link #meta} map through as per-column context.
+ *
+ * <p>
+ * A compact string sugar is supported via {@link #parse(String)}: everything after the <b>first</b> colon becomes
+ * <c>meta.field</c> — e.g. <c>"tag:status"</c> → <c>{id:"tag", meta:{field:"status"}}</c> and
+ * <c>"date"</c> → <c>{id:"date"}</c>.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link Column}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="id,meta")
+public class Render {
+
+ /** The renderer id (registry key in {@code juneau-renders.js}). */
+ public String id;
+
+ /** Optional per-column renderer metadata. Omitted from the wire when absent. */
+ public Map<String,String> meta;
+
+ /**
+ * Creates a renderer reference with the specified id and no metadata.
+ *
+ * @param id The renderer id. Must not be <jk>null</jk>.
+ * @return A new {@link Render}.
+ */
+ public static Render of(String id) {
+ var r = new Render();
+ r.id = id;
+ return r;
+ }
+
+ /**
+ * Parses a render-id string into a {@link Render}, applying the <c>"id:field"</c> string sugar.
+ *
+ * <p>
+ * Everything after the <b>first</b> colon becomes <c>meta.field</c>; a bare id (no colon) yields a
+ * metadata-free renderer.
+ *
+ * @param s The render-id string. Must not be <jk>null</jk> or blank.
+ * @return A new {@link Render}.
+ */
+ public static Render parse(String s) {
+ if (s == null || s.isBlank())
+ throw iaex("Render id must not be null or blank.");
+ var i = s.indexOf(':');
+ if (i < 0)
+ return of(s);
+ return of(s.substring(0, i)).meta("field", s.substring(i + 1));
+ }
+
+ /**
+ * Adds a metadata key/value pair to this renderer reference.
+ *
+ * @param key The metadata key. Must not be <jk>null</jk>.
+ * @param value The metadata value.
+ * @return This object.
+ */
+ public Render meta(String key, String value) {
+ if (meta == null)
+ meta = new LinkedHashMap<>();
+ meta.put(key, value);
+ return this;
+ }
+}
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
new file mode 100644
index 0000000000..3ff18d7014
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RibbonAction.java
@@ -0,0 +1,451 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A ribbon/toolbar action descriptor in the {@code VIEW_META} wire contract (design doc §6.5).
+ *
+ * <p>
+ * A single bean discriminated by {@link #type}; only the fields relevant to a given action type are populated, and
+ * the serializer omits the rest (null-valued properties are dropped). Build actions via the static factory methods
+ * ({@link #export(String...)}, {@link #refresh()}, {@link #columnSearchToggle()}, {@link #option(String)},
+ * {@link #optionGroup(String)}, {@link #divider()}).
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ViewDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="type,buttons,optional,id,title,group,column,value,param,persist,symbol,color,deselectable,options")
+public class RibbonAction {
+
+ /** The discriminator: {@code export}/{@code refresh}/{@code columnSearchToggle}/{@code option}/{@code optionGroup}/{@code divider}. */
+ public String type;
+
+ /** For {@code export}: the always-on button ids (e.g. {@code ["copy","csv"]}). */
+ public List<String> buttons;
+
+ /** For {@code export}: feature-detected optional button ids (e.g. {@code ["excel","pdf"]}). */
+ public List<String> optional;
+
+ /** The action id (for {@code option}/{@code optionGroup}). */
+ public String id;
+
+ /** The display title. */
+ public String title;
+
+ /**
+ * An opaque grouping key (visual-parity design doc §4.A). Adjacent actions sharing the same non-<jk>null</jk>
+ * {@code group} value render as ONE segmented ribbon cluster (shared borders, rounded only on the outer ends) -
+ * see {@code juneau-ribbon.js}'s {@code buildRibbon(...)}. An {@code export} action's own resolved buttons are
+ * always clustered together even when {@code group} is unset (one action, one visual cluster); this field
+ * exists so UNRELATED, adjacent actions (e.g. a {@code columnSearchToggle} and a column-scoped {@code option})
+ * can be clustered into one ribbon too. A {@code divider} always breaks an open cluster.
+ */
+ public String group;
+
+ /** For a column-scoped {@code option}: the target column key. */
+ public String column;
+
+ /** For an {@code option}: the value contributed to the query when active. */
+ public String value;
+
+ /** For a custom (non-column-scoped) {@code option}: the request param name the endpoint must parse. */
+ public String param;
+
+ /** Whether the {@code option} toggle state is persisted to {@code localStorage}. */
+ public Boolean persist;
+
+ /** Optional glyph/symbol hint. */
+ public String symbol;
+
+ /** Optional color hint. */
+ public String color;
+
+ /** For {@code optionGroup}: whether the select-one cluster can be fully deselected. */
+ public Boolean deselectable;
+
+ /** For {@code optionGroup}: the member options. */
+ public List<Opt> options;
+
+ /**
+ * A member option of an {@code optionGroup} (design doc §6.5).
+ *
+ * @since 10.0.0
+ */
+ @BeanType(properties="id,title,column,value,param,symbol,color")
+ public static class Opt {
+
+ /** The option id. */
+ public String id;
+
+ /** The display title. */
+ public String title;
+
+ /** The target column key (column-scoped option). */
+ public String column;
+
+ /** The value contributed to the query when active. */
+ public String value;
+
+ /** The custom request param name (non-column-scoped option). */
+ public String param;
+
+ /** Optional glyph/symbol hint. */
+ public String symbol;
+
+ /** Optional color hint. */
+ public String color;
+
+ /**
+ * Creates an option with the specified id.
+ *
+ * @param id The option id. Must not be <jk>null</jk>.
+ * @return A new {@link Opt}.
+ */
+ public static Opt of(String id) {
+ var o = new Opt();
+ o.id = id;
+ return o;
+ }
+
+ /**
+ * Sets the display title.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Opt title(String value) {
+ title = value;
+ return this;
+ }
+
+ /**
+ * Sets the target column key.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Opt column(String value) {
+ column = value;
+ return this;
+ }
+
+ /**
+ * Sets the contributed value.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Opt value(String value) {
+ this.value = value;
+ return this;
+ }
+
+ /**
+ * Sets the custom request param name.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Opt param(String value) {
+ param = value;
+ return this;
+ }
+
+ /**
+ * Sets the color hint.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Opt color(String value) {
+ color = value;
+ return this;
+ }
+
+ /**
+ * Sets the symbol hint.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Opt symbol(String value) {
+ symbol = value;
+ return this;
+ }
+ }
+
+ /**
+ * Creates an {@code export} action with the specified always-on button ids.
+ *
+ * @param buttons The always-on button ids (MVP: {@code "copy"}, {@code "csv"}).
+ * @return A new {@link RibbonAction}.
+ */
+ public static RibbonAction export(String...buttons) {
+ var a = new RibbonAction();
+ a.type = "export";
+ a.buttons = new ArrayList<>(Arrays.asList(buttons));
+ return a;
+ }
+
+ /**
+ * Sets the feature-detected optional export button ids (e.g. {@code "excel"}, {@code "pdf"}).
+ *
+ * @param optional The optional button ids.
+ * @return This object.
+ */
+ public RibbonAction optional(String...optional) {
+ this.optional = new ArrayList<>(Arrays.asList(optional));
+ return this;
+ }
+
+ /**
+ * Creates a {@code refresh} action (re-draw / reload the table).
+ *
+ * @return A new {@link RibbonAction}.
+ */
+ public static RibbonAction refresh() {
+ var a = new RibbonAction();
+ a.type = "refresh";
+ return a;
+ }
+
+ /**
+ * Creates a {@code columnSearchToggle} action (show/hide per-column search inputs).
+ *
+ * @return A new {@link RibbonAction}.
+ */
+ public static RibbonAction columnSearchToggle() {
+ var a = new RibbonAction();
+ a.type = "columnSearchToggle";
+ return a;
+ }
+
+ /**
+ * Creates an {@code option} action (a server-query toggle) with the specified id.
+ *
+ * @param id The option id. Must not be <jk>null</jk>.
+ * @return A new {@link RibbonAction}.
+ */
+ public static RibbonAction option(String id) {
+ var a = new RibbonAction();
+ a.type = "option";
+ a.id = id;
+ return a;
+ }
+
+ /**
+ * Creates an {@code optionGroup} action (a select-one cluster) with the specified id.
+ *
+ * @param id The group id. Must not be <jk>null</jk>.
+ * @return A new {@link RibbonAction}.
+ */
+ public static RibbonAction optionGroup(String id) {
+ var a = new RibbonAction();
+ a.type = "optionGroup";
+ a.id = id;
+ return a;
+ }
+
+ /**
+ * Creates a {@code divider} action (a visual separator).
+ *
+ * @return A new {@link RibbonAction}.
+ */
+ public static RibbonAction divider() {
+ var a = new RibbonAction();
+ a.type = "divider";
+ return a;
+ }
+
+ /**
+ * Sets the display title.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RibbonAction title(String value) {
+ title = value;
+ return this;
+ }
+
+ /**
+ * Sets the grouping key (visual-parity design doc §4.A) - adjacent actions sharing the same key render as one
+ * segmented ribbon cluster.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RibbonAction group(String value) {
+ group = value;
+ return this;
+ }
+
+ /**
+ * Sets the target column key (column-scoped option).
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RibbonAction column(String value) {
+ column = value;
+ return this;
+ }
+
+ /**
+ * Sets the value contributed to the query when the option is active.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RibbonAction value(String value) {
+ this.value = value;
+ return this;
+ }
+
+ /**
+ * Sets the custom request param name (non-column-scoped option).
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RibbonAction param(String value) {
+ param = value;
+ return this;
+ }
+
+ /**
+ * Sets whether the option toggle state is persisted to {@code localStorage}.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RibbonAction persist(boolean value) {
+ persist = value;
+ return this;
+ }
+
+ /**
+ * Sets the glyph/symbol hint.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RibbonAction symbol(String value) {
+ symbol = value;
+ return this;
+ }
+
+ /**
+ * Sets the color hint.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RibbonAction color(String value) {
+ color = value;
+ return this;
+ }
+
+ /**
+ * Marks an {@code optionGroup} as fully deselectable.
+ *
+ * @return This object.
+ */
+ public RibbonAction deselectable() {
+ deselectable = true;
+ return this;
+ }
+
+ /**
+ * Sets the member options of an {@code optionGroup}.
+ *
+ * @param options The member options.
+ * @return This object.
+ */
+ public RibbonAction options(Opt...options) {
+ this.options = new ArrayList<>(Arrays.asList(options));
+ return this;
+ }
+
+ /**
+ * Maps a view's ribbon <b>option</b>/<b>optionGroup</b> toggles to the DataTables request parameters they
+ * contribute <b>when active</b> (design doc §6.5, Task B.5).
+ *
+ * <p>
+ * This is the pure, DOM-free counterpart of the {@code juneau-ribbon.js} contribution logic (so the two
+ * implementations share one testable fixture and can't drift), and covers two option shapes:
+ * <ul class='spaced-list'>
+ * <li><b>Column-scoped</b> ({@code option(...).column(key).value(v)}): resolves {@code key} to its column
+ * <i>index</i> in {@link ViewDef#columns} and contributes the DataTables per-column search param
+ * <c>columns[<index>][search][value]=v</c> — consumed by {@code DataTablesQueryProtocol}.
+ * <li><b>Custom-param</b> ({@code option(...).param(name).value(v)}): contributes <c>name=v</c> verbatim. This is
+ * <b>the endpoint's responsibility to parse</b> — {@code DataTablesQueryProtocol} does <b>not</b> read
+ * custom (non-{@code columns[...]}/{@code search}/{@code order}/{@code start}/{@code length}) params.
+ * </ul>
+ *
+ * <p>
+ * An option carrying no {@code value} (or neither a {@code column} nor a {@code param}) contributes nothing;
+ * {@code refresh}/{@code columnSearchToggle}/{@code divider}/{@code export} actions are not query-contributing and
+ * are skipped. {@code optionGroup} member options are mapped uniformly with top-level options.
+ *
+ * @param viewDef The built view whose ribbon + columns are read. Must not be <jk>null</jk>.
+ * @return An insertion-ordered map of contributed request param name → value (possibly empty).
+ * @throws IllegalArgumentException If a column-scoped option references a column not present in {@code viewDef}.
+ */
+ public static Map<String,String> toQueryParams(ViewDef viewDef) {
+ var out = new LinkedHashMap<String,String>();
+ if (viewDef.ribbon != null)
+ for (var a : viewDef.ribbon) {
+ if ("option".equals(a.type))
+ addOptionParam(out, viewDef, a.column, a.param, a.value);
+ else if ("optionGroup".equals(a.type) && a.options != null)
+ for (var o : a.options)
+ addOptionParam(out, viewDef, o.column, o.param, o.value);
+ }
+ return out;
+ }
+
+ /** Adds one option's contributed param (column-scoped search value, else a custom param); a valueless option is a no-op. */
+ private static void addOptionParam(Map<String,String> out, ViewDef viewDef, String column, String param, String value) {
+ if (value == null)
+ return;
+ if (column != null)
+ out.put("columns[" + columnIndex(viewDef, column) + "][search][value]", value);
+ else if (param != null)
+ out.put(param, value);
+ }
+
+ /** Resolves a column's {@code data} key to its zero-based index in the view's column list. */
+ private static int columnIndex(ViewDef viewDef, String columnData) {
+ var cols = viewDef.columns;
+ if (cols != null)
+ for (var i = 0; i < cols.size(); i++)
+ if (columnData.equals(cols.get(i).data))
+ return i;
+ throw iaex("Ribbon option references unknown column '%s'.", columnData);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowClassRule.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowClassRule.java
new file mode 100644
index 0000000000..58ec805053
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowClassRule.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A declarative row-decorator rule in the {@code VIEW_META} wire contract (design doc §6.3).
+ *
+ * <p>
+ * Adds {@link #cssClass a CSS class} to a row when the rule matches, replacing a JS {@code createdRow} lambda.
+ * Serializes to <c>{"field":..., "op":..., "value"?:..., "class":...}</c>.
+ *
+ * <p>
+ * The {@link Op#EQ eq}/{@link Op#NE ne} operators <b>require</b> a {@link #value}; the
+ * {@link Op#PRESENT present}/{@link Op#ABSENT absent} operators test only whether {@code row[field]} is
+ * non-null/non-empty and therefore <b>omit</b> {@code value} from the wire.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ViewDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="field,op,value,class")
+public class RowClassRule {
+
+ /**
+ * The comparison operator for a {@link RowClassRule}.
+ *
+ * <p>
+ * Each constant carries its lowercase wire token (the value emitted for the {@code op} field).
+ */
+ public enum Op {
+
+ /** Row matches when {@code row[field]} equals the rule value. */
+ EQ("eq"),
+
+ /** Row matches when {@code row[field]} does not equal the rule value. */
+ NE("ne"),
+
+ /** Row matches when {@code row[field]} is non-null/non-empty (no value needed). */
+ PRESENT("present"),
+
+ /** Row matches when {@code row[field]} is missing/null (no value needed). */
+ ABSENT("absent");
+
+ private final String wire;
+
+ Op(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Returns the lowercase wire token for this operator.
+ *
+ * @return The wire token (e.g. <c>"eq"</c>).
+ */
+ public String wire() {
+ return wire;
+ }
+
+ /** Whether this operator requires a comparison value ({@code eq}/{@code ne}). */
+ boolean requiresValue() {
+ return this == EQ || this == NE;
+ }
+ }
+
+ /** The row field this rule tests. */
+ public String field;
+
+ /** The wire token of the comparison operator (see {@link Op#wire()}). */
+ public String op;
+
+ /** The comparison value for {@code eq}/{@code ne}; omitted for {@code present}/{@code absent}. */
+ public Object value;
+
+ /** The CSS class to add to a matching row (serialized as the {@code class} key). */
+ @BeanProp(name="class")
+ public String cssClass;
+
+ /**
+ * Creates a value-based rule for the {@link Op#EQ eq}/{@link Op#NE ne} operators.
+ *
+ * @param field The row field to test. Must not be <jk>null</jk>.
+ * @param op The operator. Must be {@link Op#EQ} or {@link Op#NE}.
+ * @param value The comparison value. Must not be <jk>null</jk>.
+ * @param cssClass The CSS class to add to a matching row. Must not be <jk>null</jk>.
+ * @return A new {@link RowClassRule}.
+ * @throws IllegalArgumentException If {@code op} is not value-based or {@code value} is <jk>null</jk>.
+ */
+ public static RowClassRule of(String field, Op op, Object value, String cssClass) {
+ if (! op.requiresValue())
+ throw iaex("rowClassRule op '%s' does not take a value; use the (field, op, class) form.", op.wire());
+ if (value == null)
+ throw iaex("rowClassRule op '%s' requires a non-null value.", op.wire());
+ var r = new RowClassRule();
+ r.field = field;
+ r.op = op.wire();
+ r.value = value;
+ r.cssClass = cssClass;
+ return r;
+ }
+
+ /**
+ * Creates a presence-based rule for the {@link Op#PRESENT present}/{@link Op#ABSENT absent} operators.
+ *
+ * @param field The row field to test. Must not be <jk>null</jk>.
+ * @param op The operator. Must be {@link Op#PRESENT} or {@link Op#ABSENT}.
+ * @param cssClass The CSS class to add to a matching row. Must not be <jk>null</jk>.
+ * @return A new {@link RowClassRule} with no {@code value}.
+ * @throws IllegalArgumentException If {@code op} is value-based.
+ */
+ public static RowClassRule of(String field, Op op, String cssClass) {
+ if (op.requiresValue())
+ throw iaex("rowClassRule op '%s' requires a value; use the (field, op, value, class) form.", op.wire());
+ var r = new RowClassRule();
+ r.field = field;
+ r.op = op.wire();
+ r.cssClass = cssClass;
+ return r;
+ }
+}
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
new file mode 100644
index 0000000000..e005247ad4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+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").
+ *
+ * <p>
+ * A {@link Subtab} carries a stable {@code id} (the third hash segment, {@code #pageId/tabId/<subtabId>}) and
+ * {@code label} (the sub-tab bar button text), plus the referenced {@link #view}. Built via
+ * {@link #create(String, String)} + {@link #view(ViewDef)}, mirroring the Phase B builder ergonomics.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link PageDef}
+ * <li class='jc'>{@link Tab}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="id,label,view")
+public class Subtab {
+
+ /** The stable sub-tab id (the third hash segment). */
+ public String id;
+
+ /** The sub-tab bar button text. */
+ public String label;
+
+ /** The referenced child view. */
+ public ViewDef view;
+
+ /**
+ * Starts a new {@link Subtab} builder with the specified stable id and display label.
+ *
+ * @param id The stable sub-tab id. Must not be <jk>null</jk> or blank.
+ * @param label The sub-tab bar button text.
+ * @return A new mutable {@link Subtab} to chain builder calls on.
+ */
+ public static Subtab create(String id, String label) {
+ if (id == null || id.isBlank())
+ throw iaex("Subtab id must not be null or blank.");
+ var s = new Subtab();
+ s.id = id;
+ s.label = label;
+ return s;
+ }
+
+ /**
+ * Sets the referenced child view.
+ *
+ * @param value The built child view. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Subtab view(ViewDef value) {
+ view = value;
+ return this;
+ }
+}
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
new file mode 100644
index 0000000000..2c68797f2c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.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").
+ *
+ * <p>
+ * A {@link Tab} carries a stable {@code id} (the second hash segment, {@code #pageId/<tabId>/...}) and {@code label}
+ * (the tab-bar button text), plus <b>exactly one</b> of {@link #view} or {@link #subtabs} — enforced by
+ * {@link PageDef#build()}, not by the individual setters, so builder call order stays unconstrained.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link PageDef}
+ * <li class='jc'>{@link Subtab}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="id,label,view,subtabs")
+public class Tab {
+
+ /** The stable tab id (the second hash segment), unique across the page. */
+ public String id;
+
+ /** The tab-bar button text. */
+ public String label;
+
+ /** The referenced child view (mutually exclusive with {@link #subtabs}). */
+ public ViewDef view;
+
+ /** The ordered sub-tabs (mutually exclusive with {@link #view}). */
+ public List<Subtab> subtabs;
+
+ /**
+ * Starts a new {@link Tab} builder with the specified stable id and display label.
+ *
+ * @param id The stable tab id. Must not be <jk>null</jk> or blank.
+ * @param label The tab-bar button text.
+ * @return A new mutable {@link Tab} to chain builder calls on.
+ */
+ public static Tab create(String id, String label) {
+ if (id == null || id.isBlank())
+ throw iaex("Tab id must not be null or blank.");
+ var t = new Tab();
+ t.id = id;
+ t.label = label;
+ return t;
+ }
+
+ /**
+ * Sets the referenced child view (a leaf tab with no sub-tabs).
+ *
+ * @param value The built child view. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Tab view(ViewDef value) {
+ view = value;
+ return this;
+ }
+
+ /**
+ * Sets the ordered sub-tabs.
+ *
+ * @param value The sub-tabs, in display order.
+ * @return This object.
+ */
+ public Tab subtabs(Subtab...value) {
+ subtabs = new ArrayList<>(Arrays.asList(value));
+ return this;
+ }
+
+ /**
+ * Validates this tab in isolation (design doc §"Bean model" validation rules): exactly one of {@link #view}/
+ * {@link #subtabs} must be set, and sub-tab ids must be unique <b>within this tab</b> (a sub-tab id may safely
+ * recur under a different tab — the hash's {@code tabId} segment already disambiguates the parent before
+ * {@code subtabId} is ever resolved).
+ *
+ * @throws IllegalArgumentException On any rule violation.
+ */
+ void validate() {
+ var hasView = view != null;
+ var hasSubtabs = subtabs != null && !subtabs.isEmpty();
+ if (hasView == hasSubtabs)
+ throw iaex("Tab '%s' must declare exactly one of view or subtabs.", id);
+ if (hasSubtabs) {
+ var ids = new HashSet<String>();
+ for (var s : subtabs) {
+ if (s.view == null)
+ throw iaex("Subtab '%s' in tab '%s' must declare a view.", s.id, id);
+ if (!ids.add(s.id))
+ throw iaex("Tab '%s': duplicate subtab id '%s'.", id, s.id);
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000000..cb04fb2d35
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
@@ -0,0 +1,359 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.rest.server.converter.*;
+import org.apache.juneau.rest.server.datatables.*;
+
+/**
+ * The top-level, declarative "rich DataTables view" definition — the root of the frozen {@code VIEW_META}
+ * JSON wire contract (design doc §6.10).
+ *
+ * <p>
+ * A {@link ViewDef} is an ordinary Juneau bean built via a small fluent builder ({@link #create(String)} + chained
+ * setters + {@link #build()}) and serialized with the repo's canonical compact JSON marshaller. The
+ * {@code @BeanType(properties=...)} contract pins both the <b>set</b> and the <b>order</b> of emitted keys so the
+ * 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}.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * ViewDef <jv>view</jv> = ViewDef.<jsm>create</jsm>(<js>"releases"</js>)
+ * .rowType(Release.<jk>class</jk>)
+ * .dataMode(DataMode.<jsf>SERVER</jsf>)
+ * .dataUrl(<js>"servlet:/releases/data"</js>)
+ * .defaultOrder(<js>"date"</js>, Dir.<jsf>DESC</jsf>)
+ * .columns(Column.<jsm>of</jsm>(<js>"name"</js>).title(<js>"Name"</js>))
+ * .build();
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link Column}
+ * <li class='jc'>{@link RibbonAction}
+ * <li class='jc'>{@link RowClassRule}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules")
+public class ViewDef {
+
+ /** The frozen contract version. Bumped only on a breaking wire change. */
+ public static final String CONTRACT_VERSION = "2";
+
+ /**
+ * How the table sources its rows.
+ *
+ * <p>
+ * Each constant carries the lowercase wire token emitted for the {@code dataMode} field.
+ */
+ public enum DataMode {
+
+ /** Server-side processing: DataTables posts draw/search/order params and the endpoint returns a page. */
+ SERVER("server"),
+
+ /** Client-side processing: the full row set is delivered once and DataTables paginates in-browser. */
+ CLIENT("client");
+
+ private final String wire;
+
+ DataMode(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Returns the lowercase wire token for this mode.
+ *
+ * @return The wire token (e.g. <c>"server"</c>).
+ */
+ public String wire() {
+ return wire;
+ }
+ }
+
+ /**
+ * A sort direction.
+ *
+ * <p>
+ * Each constant carries the lowercase wire token emitted for an order entry's {@code dir} field.
+ */
+ public enum Dir {
+
+ /** Ascending. */
+ ASC("asc"),
+
+ /** Descending. */
+ DESC("desc");
+
+ private final String wire;
+
+ Dir(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Returns the lowercase wire token for this direction.
+ *
+ * @return The wire token (e.g. <c>"desc"</c>).
+ */
+ public String wire() {
+ return wire;
+ }
+ }
+
+ /**
+ * A single default-order entry: which column to sort by and in which direction.
+ *
+ * @since 10.0.0
+ */
+ @BeanType(properties="data,dir")
+ public static class OrderEntry {
+
+ /** The column data key to sort by. */
+ public String data;
+
+ /** The sort direction wire token (see {@link Dir#wire()}). */
+ public String dir;
+
+ /**
+ * Creates an order entry.
+ *
+ * @param data The column data key. Must not be <jk>null</jk>.
+ * @param dir The sort direction. Must not be <jk>null</jk>.
+ * @return A new {@link OrderEntry}.
+ */
+ public static OrderEntry of(String data, Dir dir) {
+ var e = new OrderEntry();
+ e.data = data;
+ e.dir = dir.wire();
+ return e;
+ }
+ }
+
+ /** The frozen contract version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
+ public String contractVersion = CONTRACT_VERSION;
+
+ /** The stable view id (also the {@code localStorage} state-key root). */
+ public String id;
+
+ /** Diagnostic: the simple name of the row bean type this view renders. */
+ public String rowType;
+
+ /** How the table sources rows (wire token; see {@link DataMode#wire()}). */
+ public String dataMode;
+
+ /** The URL the client hits for row data. */
+ public String dataUrl;
+
+ /** The initial sort order. */
+ public List<OrderEntry> defaultOrder;
+
+ /** The column descriptors, in display order. */
+ public List<Column> columns;
+
+ /** The ribbon/toolbar actions, in display order. */
+ public List<RibbonAction> ribbon;
+
+ /** The declarative row-decorator rules. */
+ public List<RowClassRule> rowClassRules;
+
+ /**
+ * 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;
+
+ /**
+ * Starts a new {@link ViewDef} builder with the specified stable view id.
+ *
+ * @param id The stable view id. Must not be <jk>null</jk> or blank.
+ * @return A new mutable {@link ViewDef} to chain builder calls on.
+ */
+ public static ViewDef create(String id) {
+ if (id == null || id.isBlank())
+ throw iaex("ViewDef id must not be null or blank.");
+ var v = new ViewDef();
+ v.id = id;
+ return v;
+ }
+
+ /**
+ * Sets the row bean type; its {@link Class#getSimpleName() simple name} drives the {@link #rowType} diagnostic.
+ *
+ * @param value The row bean type. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public ViewDef rowType(Class<?> value) {
+ rowType = value.getSimpleName();
+ rowTypeClass = value;
+ return this;
+ }
+
+ /**
+ * Sets how the table sources its rows.
+ *
+ * @param value The data mode. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public ViewDef dataMode(DataMode value) {
+ dataMode = value.wire();
+ return this;
+ }
+
+ /**
+ * Sets the URL the client hits for row data.
+ *
+ * @param value The data URL. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public ViewDef dataUrl(String value) {
+ dataUrl = value;
+ return this;
+ }
+
+ /**
+ * Sets the initial sort order to a single column/direction entry.
+ *
+ * @param data The column data key to sort by. Must not be <jk>null</jk>.
+ * @param dir The sort direction. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public ViewDef defaultOrder(String data, Dir dir) {
+ defaultOrder = new ArrayList<>(List.of(OrderEntry.of(data, dir)));
+ return this;
+ }
+
+ /**
+ * Sets the column descriptors.
+ *
+ * @param value The columns, in display order.
+ * @return This object.
+ */
+ public ViewDef columns(Column...value) {
+ columns = new ArrayList<>(Arrays.asList(value));
+ return this;
+ }
+
+ /**
+ * Sets the ribbon/toolbar actions.
+ *
+ * @param value The actions, in display order.
+ * @return This object.
+ */
+ public ViewDef ribbon(RibbonAction...value) {
+ ribbon = new ArrayList<>(Arrays.asList(value));
+ return this;
+ }
+
+ /**
+ * Adds a value-based row-decorator rule ({@link RowClassRule.Op#EQ eq}/{@link RowClassRule.Op#NE ne}).
+ *
+ * @param field The row field to test. Must not be <jk>null</jk>.
+ * @param op The operator. Must be {@link RowClassRule.Op#EQ} or {@link RowClassRule.Op#NE}.
+ * @param value The comparison value. Must not be <jk>null</jk>.
+ * @param cssClass The CSS class to add to a matching row. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public ViewDef rowClassRule(String field, RowClassRule.Op op, Object value, String cssClass) {
+ return addRowClassRule(RowClassRule.of(field, op, value, cssClass));
+ }
+
+ /**
+ * Adds a presence-based row-decorator rule ({@link RowClassRule.Op#PRESENT present}/{@link RowClassRule.Op#ABSENT absent}).
+ *
+ * @param field The row field to test. Must not be <jk>null</jk>.
+ * @param op The operator. Must be {@link RowClassRule.Op#PRESENT} or {@link RowClassRule.Op#ABSENT}.
+ * @param cssClass The CSS class to add to a matching row. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public ViewDef rowClassRule(String field, RowClassRule.Op op, String cssClass) {
+ return addRowClassRule(RowClassRule.of(field, op, cssClass));
+ }
+
+ private ViewDef addRowClassRule(RowClassRule rule) {
+ if (rowClassRules == null)
+ rowClassRules = new ArrayList<>();
+ rowClassRules.add(rule);
+ return this;
+ }
+
+ /**
+ * Finalizes the builder and returns the wire-ready {@link ViewDef}.
+ *
+ * <p>
+ * When no columns were declared via {@link #columns(Column...)} but a {@link #rowType(Class) row type} was set,
+ * the column list is auto-seeded from {@link DataTablesColumns#of(Class)} — one {@link Column} per readable
+ * bean property, in bean-property order, carrying the DataTables-native {@code data}/{@code title}/
+ * {@code orderable}/{@code searchable} fields. Explicitly-declared columns are left untouched.
+ *
+ * @return This object.
+ */
+ public ViewDef build() {
+ if (columns == null && rowTypeClass != null)
+ columns = seedColumns(rowTypeClass);
+ return this;
+ }
+
+ /**
+ * Server-side wiring helper (design doc §6, Task B.5): builds the {@link QueryableSettings} bean that selects the
+ * DataTables server-side-processing protocol for this view's data op.
+ *
+ * <p>
+ * Register the returned bean in the data endpoint's resource (a {@code @Bean} factory method) alongside
+ * {@code @Rest(converters=ProtocolQueryable.class)}; the op then returns its row {@code List} and
+ * {@link ProtocolQueryable} parses the DataTables request, runs the shared query engine, and wraps the page in a
+ * {@link DataTablesResults} envelope. Binding the protocol's {@code rowType} to this view's row type keeps
+ * array-index (positional) column resolution in sync with the view definition.
+ *
+ * <p>
+ * The settings must be registered <b>at the op/resource level</b> (a mixin can't inject config into a host's other
+ * endpoints). Hand-wiring the equivalent
+ * <c>QueryableSettings.create().protocol(new DataTablesQueryProtocol(rowType)).build()</c> stays fully supported;
+ * this helper simply keeps the row type in one source of truth with the {@link ViewDef}.
+ *
+ * @return A new {@link QueryableSettings} whose protocol is a {@link DataTablesQueryProtocol} bound to this view's
+ * row type.
+ * @throws IllegalArgumentException If no {@link #rowType(Class) row type} was set on this view.
+ */
+ public QueryableSettings queryableSettings() {
+ if (rowTypeClass == null)
+ throw iaex("ViewDef.queryableSettings() requires a rowType; call .rowType(...) before building the view.");
+ return QueryableSettings.create().protocol(new DataTablesQueryProtocol(rowTypeClass)).build();
+ }
+
+ /** Decorates the {@link DataTablesColumns#of(Class)} descriptor maps into {@link Column} beans. */
+ private static List<Column> seedColumns(Class<?> rowType) {
+ var out = new ArrayList<Column>();
+ for (var m : DataTablesColumns.of(rowType)) {
+ out.add(Column.of((String) m.get("data"))
+ .title((String) m.get("title"))
+ .orderable(Boolean.TRUE.equals(m.get("orderable")))
+ .searchable(Boolean.TRUE.equals(m.get("searchable"))));
+ }
+ return out;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
new file mode 100644
index 0000000000..60283db7f4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.html5.*;
+import org.apache.juneau.marshall.*;
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Builds the HTML delivery shell for a {@link ViewDef} — the {@code data-juneau-view} table plus the
+ * {@code <script type="application/json">} VIEW_META sidecar the {@code juneau-views.js} runtime consumes (design
+ * doc §6.1).
+ *
+ * <p>
+ * Mirrors the sibling {@link org.apache.juneau.rest.server.datatables.DataTablesTable DataTablesTable} pattern, but
+ * emits the <b>distinct</b> {@link #MARKER_ATTR data-juneau-view} marker (never {@code data-juneau-datatable}) so
+ * the VIEW_META path and the plain-DataTables path never collide on the same node. The returned {@link Div} carries:
+ * <ul class='spaced-list'>
+ * <li>a {@code <table>} with a stable {@code id} (the {@code $('#id')} selector) + the {@code data-juneau-view="<id>"}
+ * marker and a {@code <thead>} of column titles, and
+ * <li>a sibling {@code <script type="application/json" id="juneau-view:<id>">} sidecar carrying the serialized
+ * {@link ViewDef}.
+ * </ul>
+ *
+ * <p>
+ * When {@code rows} are supplied (client-side datasets), they are rendered into a {@code <tbody>} up front; in the
+ * server-side dogfood path {@code rows} is omitted and the rows arrive via ajax draws against
+ * {@link ViewDef#dataUrl}.
+ *
+ * <h5 class='section'>Escaping contract (security-critical — design doc §6.1):</h5>
+ * <p>
+ * The VIEW_META JSON is emitted as the text content of a {@code <script type="application/json">} element. Per the
+ * HTML spec such content is <b>raw text</b> (HTML entities are NOT decoded inside it) and must not contain the
+ * substring {@code </} (nor {@code <!--}), or it would prematurely terminate the element. This emitter therefore
+ * escapes every {@code <} in the serialized JSON to its JSON unicode escape {@code \u003c} <b>before</b> insertion,
+ * which neutralizes {@code </script>}, {@code <script}, and {@code <!--} break-outs while keeping the payload valid,
+ * round-trippable JSON. The JSON is inserted as verbatim raw content (via
+ * {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) rawText}) so Juneau's normal XML/HTML text
+ * entity-encoding does not corrupt the {@code application/json} payload (it would otherwise turn {@code &}/{@code >}
+ * into {@code &}/{@code >}, which browsers do NOT decode inside a raw-text {@code <script>}). Because
+ * {@code rawText} is backed by a {@code String} (not a one-shot {@link java.io.Reader}), the returned bean is fully
+ * re-serializable — it survives a serialize→object→serialize cycle and the full {@code HtmlDoc} page
+ * path.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ViewDef}
+ * <li class='jc'>{@link org.apache.juneau.rest.server.datatables.DataTablesTable}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class ViewTable {
+
+ /** Marker attribute the {@code juneau-views.js} runtime looks for to auto-initialize a view table. */
+ public static final String MARKER_ATTR = "data-juneau-view";
+
+ /** Prefix of the sidecar {@code <script>} element id: {@code juneau-view:<viewId>}. */
+ public static final String SIDECAR_ID_PREFIX = "juneau-view:";
+
+ private ViewTable() {}
+
+ /**
+ * Builds the view-table shell for a server-side view (no up-front rows), using the default marshalling context.
+ *
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view>} and the JSON sidecar.
+ */
+ public static Div of(ViewDef viewDef) {
+ return of(MarshallingContext.DEFAULT, viewDef, null);
+ }
+
+ /**
+ * Builds the view-table shell and renders {@code rows} into the {@code <tbody>} (client-side datasets), using the
+ * default marshalling context.
+ *
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, and the
+ * JSON sidecar.
+ */
+ public static Div of(ViewDef viewDef, Collection<?> rows) {
+ return of(MarshallingContext.DEFAULT, viewDef, rows);
+ }
+
+ /**
+ * Builds the view-table shell using the specified marshalling context for bean-property cell reads.
+ *
+ * @param ctx The marshalling context used to read bean-property cell values. Must not be <jk>null</jk>.
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, and the
+ * JSON sidecar.
+ */
+ public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows) {
+ var id = viewDef.id;
+ var cols = viewDef.columns == null ? List.<Column>of() : viewDef.columns;
+
+ // <thead> of column titles (falling back to the data key when no title was set).
+ var headerCells = new ArrayList<>(cols.size());
+ for (var c : cols)
+ headerCells.add(th(c.title == null ? c.data : c.title));
+
+ var tableChildren = new ArrayList<>();
+ tableChildren.add(thead(tr(headerCells.toArray())));
+
+ if (rows != null) {
+ var bodyRows = new ArrayList<>(rows.size());
+ for (var row : rows) {
+ var cells = new ArrayList<>(cols.size());
+ for (var c : cols) {
+ var v = value(ctx, row, c.data);
+ cells.add(td(v == null ? "" : v));
+ }
+ bodyRows.add(tr(cells.toArray()));
+ }
+ tableChildren.add(tbody(bodyRows.toArray()));
+ }
+
+ var table = table(tableChildren.toArray()).id(id).attr(MARKER_ATTR, id);
+
+ // Sidecar: serialize the VIEW_META, escape '<' -> \u003c, then insert as RAW content (see class javadoc).
+ var json = escapeForScript(Json.of(viewDef));
+ var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
+
+ return div(table, sidecar);
+ }
+
+ /**
+ * Escapes every {@code <} in a serialized-JSON string to its JSON unicode escape {@code \u003c} so the JSON can be
+ * safely embedded as raw-text {@code <script>} content (design doc §6.1).
+ *
+ * <p>
+ * A blanket {@code <} → {@code \u003c} substitution is safe: {@code <} never appears inside a JSON escape
+ * sequence, and {@code \u003c} is the valid JSON encoding of {@code <}, so the result stays parseable JSON that
+ * decodes back to the original value. Neutralizing {@code <} alone covers every HTML break-out sequence for a
+ * raw-text element ({@code </script}, {@code <script}, {@code <!--}).
+ *
+ * @param json The serialized JSON. Must not be <jk>null</jk>.
+ * @return The break-out-safe JSON.
+ */
+ static String escapeForScript(String json) {
+ return json.replace("<", "\\u003c");
+ }
+
+ /** Reads a column value from a row: a direct key lookup for a {@code Map}, a bean-property read otherwise. */
+ private static Object value(MarshallingContext ctx, Object row, String key) {
+ if (row instanceof java.util.Map<?,?> m)
+ return m.get(key);
+ return ctx.toBeanMap(row).get(key);
+ }
+}
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
new file mode 100644
index 0000000000..79ae6088a4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
@@ -0,0 +1,304 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.zip.*;
+
+import org.apache.juneau.commons.utils.*;
+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.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).
+ *
+ * <p>
+ * Compose into a host resource via {@link Rest#mixins() @Rest(mixins=ViewsMixin.class)}; the asset URLs then become
+ * available alongside the host's own endpoints, so browser pages can load the runtime without the application hosting
+ * it itself. This mirrors the sibling {@code DataTablesMixin}/{@code ConsoleChromeMixin} serving pattern (those
+ * modules are not dependencies of this one, so they are not linked here).
+ *
+ * <h5 class='section'>What this ships (and what it deliberately does not):</h5>
+ * <p>
+ * This is an Apache project; the <a class="doclink" href="https://datatables.net">DataTables</a> library and jQuery are
+ * <b>not</b> ASF category-A licensed, so <b>they are not bundled here.</b> Only the thin, first-party, clean-room
+ * runtime is served; the DataTables/jQuery/Buttons libraries stay <b>caller-provided</b> (CDN or self-hosted). The
+ * base {@code juneau-views.css} {@code .tag} chip is dependency-free (neutral, no colors); {@code console-ui}'s
+ * {@code chrome.css} themes the same {@code .tag.<domain>.<value>} classes when present, but this module takes
+ * <b>no</b> dependency on it.
+ *
+ * <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
+ * 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
+ * serving a browser's stale cached copy after every rebuild - the content hash changes the instant the bytes do,
+ * with no SNAPSHOT special-casing needed. {@code buildVersion} is the framework's
+ * {@link Package#getImplementationVersion() implementation version}, falling back to {@code "dev"} when running from an
+ * unpackaged (IDE/test) classpath. The served endpoint ignores the query string, so any (or no) {@code ?v=} still
+ * resolves to the same asset.
+ *
+ * <h5 class='section'>Contract-version handshake:</h5>
+ * <p>
+ * {@link #CONTRACT_VERSION} is the single wire-contract discriminator, kept in one source of truth with the value the
+ * model emits ({@link ViewDef#CONTRACT_VERSION}). {@code juneau-views.js} bakes in the same value so the client can
+ * fail loud when a served {@code VIEW_META} sidecar's {@code contractVersion} differs from the runtime's.
+ *
+ * <h5 class='section'>Mixin-only deployment:</h5>
+ * <p>
+ * The mount paths are pinned at the op level by {@code @RestGet(path=...)} on the serving methods; a class-level
+ * {@code @Rest(paths=...)} would be silently ignored under the mixin pattern.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ViewDef}
+ * <li class='jc'>{@link ViewTable}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+// @formatter:off
+@Rest
+public class ViewsMixin {
+
+ /** The URL path at which the client initializer is served (relative to the host mount). */
+ public static final String VIEWS_JS_PATH = "/juneau-views.js";
+
+ /** The URL path at which the ribbon runtime is served (relative to the host mount). */
+ public static final String RIBBON_JS_PATH = "/juneau-ribbon.js";
+
+ /** The URL path at which the renderer registry is served (relative to the host mount). */
+ public static final String RENDERS_JS_PATH = "/juneau-renders.js";
+
+ /** The URL path at which the base view stylesheet is served (relative to the host mount). */
+ public static final String VIEWS_CSS_PATH = "/juneau-views.css";
+
+ /** The URL path at which the icon registry is served (relative to the host mount). */
+ 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.
+ */
+ public static final String PAGES_JS_PATH = "/juneau-pages.js";
+
+ /**
+ * The frozen {@code VIEW_META} contract-version handshake constant, kept in one source of truth with the value the
+ * model emits ({@link ViewDef#CONTRACT_VERSION}).
+ */
+ public static final String CONTRACT_VERSION = ViewDef.CONTRACT_VERSION;
+
+ /** Classpath location of the shipped initializer. */
+ static final String VIEWS_JS_RESOURCE = "/org/apache/juneau/views/juneau-views.js";
+
+ /** Classpath location of the shipped ribbon runtime. */
+ static final String RIBBON_JS_RESOURCE = "/org/apache/juneau/views/juneau-ribbon.js";
+
+ /** Classpath location of the shipped renderer registry. */
+ static final String RENDERS_JS_RESOURCE = "/org/apache/juneau/views/juneau-renders.js";
+
+ /** Classpath location of the shipped base stylesheet. */
+ static final String VIEWS_CSS_RESOURCE = "/org/apache/juneau/views/juneau-views.css";
+
+ /** 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). */
+ static final String PAGES_JS_RESOURCE = "/org/apache/juneau/views/juneau-pages.js";
+
+ /** Content type emitted for the JavaScript assets. */
+ static final String JS_CONTENT_TYPE = "text/javascript;charset=utf-8";
+
+ /** Content type emitted for the stylesheet asset. */
+ static final String CSS_CONTENT_TYPE = "text/css;charset=utf-8";
+
+ /** {@code Cache-Control} header emitted for every asset (1 day). */
+ static final String CACHE_CONTROL = "max-age=86400, public";
+
+ /** Per-resource byte cache, populated on first request (the shipped files never vary). */
+ private static final Map<String,byte[]> CACHE = new ConcurrentHashMap<>();
+
+ /** Per-path content-hash cache, populated on first {@link #viewAssetUrl(String)} call (mirrors {@link #CACHE}). */
+ private static final Map<String,String> HASH_CACHE = new ConcurrentHashMap<>();
+
+ /**
+ * [GET /juneau-views.js] — serve the client initializer.
+ *
+ * @return The initializer as a JavaScript {@link HttpResource}.
+ */
+ @RestGet(
+ path=VIEWS_JS_PATH,
+ summary="Juneau rich-view client initializer",
+ description="First-party JavaScript that auto-initializes <table data-juneau-view> elements from their VIEW_META sidecar.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getViewsScript() {
+ return serve(VIEWS_JS_RESOURCE, JS_CONTENT_TYPE);
+ }
+
+ /**
+ * [GET /juneau-ribbon.js] — serve the ribbon/toolbar runtime.
+ *
+ * @return The ribbon runtime as a JavaScript {@link HttpResource}.
+ */
+ @RestGet(
+ path=RIBBON_JS_PATH,
+ summary="Juneau rich-view ribbon runtime",
+ description="First-party JavaScript that builds the ribbon/toolbar from a view's VIEW_META ribbon actions.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getRibbonScript() {
+ return serve(RIBBON_JS_RESOURCE, JS_CONTENT_TYPE);
+ }
+
+ /**
+ * [GET /juneau-renders.js] — serve the renderer registry.
+ *
+ * @return The renderer registry as a JavaScript {@link HttpResource}.
+ */
+ @RestGet(
+ path=RENDERS_JS_PATH,
+ summary="Juneau rich-view renderer registry",
+ description="First-party, dependency-free JavaScript cell-renderer registry consumed by the view initializer.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getRendersScript() {
+ return serve(RENDERS_JS_RESOURCE, JS_CONTENT_TYPE);
+ }
+
+ /**
+ * [GET /juneau-views.css] — serve the base view stylesheet.
+ *
+ * @return The base stylesheet as a CSS {@link HttpResource}.
+ */
+ @RestGet(
+ path=VIEWS_CSS_PATH,
+ summary="Juneau rich-view base stylesheet",
+ description="First-party base '.tag' chip stylesheet (neutral, no colors); console-ui themes the same classes when present.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getViewsStylesheet() {
+ return serve(VIEWS_CSS_RESOURCE, CSS_CONTENT_TYPE);
+ }
+
+ /**
+ * [GET /juneau-icons.js] — serve the icon registry.
+ *
+ * @return The icon registry as a JavaScript {@link HttpResource}.
+ */
+ @RestGet(
+ path=ICONS_JS_PATH,
+ summary="Juneau rich-view icon registry",
+ description="First-party, dependency-free JavaScript icon registry (name -> inline-SVG markup) consumed by the ribbon/paging-pill runtime.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getIconsScript() {
+ return serve(ICONS_JS_RESOURCE, JS_CONTENT_TYPE);
+ }
+
+ /**
+ * [GET /juneau-pages.js] — serve the opt-in tabs/sub-tabs page runtime (TODO-399 Phase C).
+ *
+ * @return The page runtime as a JavaScript {@link HttpResource}.
+ */
+ @RestGet(
+ path=PAGES_JS_PATH,
+ summary="Juneau rich-view tabs/sub-tabs page runtime",
+ description="First-party, opt-in JavaScript that hash-routes a PageDef's tabs/sub-tabs and lazy-inits each panel's view table on first activation.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getPagesScript() {
+ return serve(PAGES_JS_RESOURCE, JS_CONTENT_TYPE);
+ }
+
+ /**
+ * Returns the servlet-relative URL for a served asset, carrying a {@code ?v=<buildVersion>-<hash8>} content-
+ * sensitive cache-buster suitable for a page's {@code head=} block (see the class Javadoc's cache-busting
+ * section for why the buster is content- rather than purely version-keyed).
+ *
+ * @param path One of the asset path constants ({@link #VIEWS_JS_PATH}, {@link #RIBBON_JS_PATH},
+ * {@link #RENDERS_JS_PATH}, {@link #VIEWS_CSS_PATH}, {@link #ICONS_JS_PATH}, {@link #PAGES_JS_PATH}).
+ * @return The servlet-relative asset URL with the version+content-hash cache-buster appended.
+ */
+ public static String viewAssetUrl(String path) {
+ return "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);
+ return HttpResourceBean.of(
+ ByteArrayBody.of(bytes, contentType),
+ list(ContentType.of(contentType), CacheControl.of(CACHE_CONTROL))
+ );
+ }
+
+ /** Reads a shipped classpath asset into a byte array, wrapping the (effectively unreachable) IO failure. */
+ private static byte[] load(String resource) {
+ try (var in = ViewsMixin.class.getResourceAsStream(resource)) {
+ if (in == null)
+ throw new IOException("Classpath resource not found: " + resource);
+ return IoUtils.readBytes(in);
+ } catch (IOException e) { // HTT: unreachable - the asset ships in the same jar as this class.
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Resolves the framework build version for asset cache-busting, falling back to {@code "dev"} when unset (e.g.
+ * running from an IDE/test classpath rather than a packaged jar).
+ */
+ private static String buildVersion() {
+ var v = ViewsMixin.class.getPackage().getImplementationVersion();
+ return v == null ? "dev" : v; // HTT: the non-null branch only fires from a packaged jar manifest - unreachable against unpackaged target/classes.
+ }
+
+ /** Computes (and caches) the given asset path's 8-hex-char content hash from its served bytes. */
+ private static String contentHash(String path) {
+ return HASH_CACHE.computeIfAbsent(path, p -> hash8(CACHE.computeIfAbsent(resourceFor(p), ViewsMixin::load)));
+ }
+
+ /** Maps a public asset path constant to its classpath resource constant (content-hashing only; routing itself is by {@code @RestGet(path=...)}). */
+ private static String resourceFor(String path) {
+ if (VIEWS_JS_PATH.equals(path)) return VIEWS_JS_RESOURCE;
+ if (RIBBON_JS_PATH.equals(path)) return RIBBON_JS_RESOURCE;
+ if (RENDERS_JS_PATH.equals(path)) return RENDERS_JS_RESOURCE;
+ if (VIEWS_CSS_PATH.equals(path)) return VIEWS_CSS_RESOURCE;
+ if (ICONS_JS_PATH.equals(path)) return ICONS_JS_RESOURCE;
+ if (PAGES_JS_PATH.equals(path)) return PAGES_JS_RESOURCE;
+ throw new IllegalArgumentException("Unknown asset path: " + path);
+ }
+
+ /** Formats a CRC32 checksum of {@code bytes} as a zero-padded 8-hex-char content hash. */
+ private static String hash8(byte[] bytes) {
+ var crc = new CRC32();
+ crc.update(bytes);
+ return String.format("%08x", crc.getValue());
+ }
+}
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
new file mode 100644
index 0000000000..94833efff6
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/package-info.java
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+/**
+ * A typed, declarative "rich DataTables view" toolkit that serializes to the frozen {@code VIEW_META} JSON wire
+ * 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):
+ * 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}/
+ * {@code juneau-renders.js} runtime consumes to wire up a fully-featured DataTable.
+ *
+ * <p>
+ * The module owns a dependency-free base {@code .tag} chip stylesheet and takes <b>no</b> hard dependency on
+ * {@code juneau-rest-server-console-ui}: the base chip keeps client-rendered views legible standalone, while
+ * console-ui's palette themes the shared {@code .tag.<domain>.<value>} class-name contract when present.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link org.apache.juneau.rest.server.datatables.DataTablesQueryProtocol}
+ * <li class='jc'>{@link org.apache.juneau.rest.server.datatables.DataTablesColumns}
+ * <li class='link'><a class="doclink" href="https://datatables.net/manual/server-side">DataTables Server-Side Processing</a>
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+package org.apache.juneau.rest.server.views;
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js
new file mode 100644
index 0000000000..fe42d7884d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-icons.js - dependency-free icon registry for the Apache Juneau rich-view toolkit (DataTables ribbon
+ * visual-parity pass).
+ *
+ * A pure, library-free registry mapping icon names to inline-SVG markup, mirroring juneau-renders.js's
+ * registerRenderer/resolveRenderer pattern exactly (registerIcon/resolveIcon). Apps can register additional or
+ * overriding icons at runtime via window.JuneauViews.icons.registerIcon(name, svgMarkup).
+ *
+ * Ships the glyphs this pass's ribbon/paging-ribbon components actually use - each a minimal, single-<path>,
+ * currentColor-filled Material Symbols Outlined glyph, sourced from Google's public google/material-design-icons
+ * repository (Apache License, Version 2.0) - see the repo-root NOTICE file for the attribution paragraph. This is
+ * a clean-room, spec-from-behavior asset, NOT ported from IRS (design doc §6).
+ */
+(function () {
+ "use strict";
+
+ var NS = window.JuneauViews = window.JuneauViews || {};
+
+ var registry = NS._icons = NS._icons || {};
+
+ /** Registers (or overrides) an icon's inline-SVG markup under `name`. */
+ function registerIcon(name, svgMarkup) {
+ registry[name] = svgMarkup;
+ return registry[name];
+ }
+
+ /** Looks up an icon's markup by name; returns null when unknown (callers fall back to rendering raw text). */
+ function resolveIcon(name) {
+ return Object.prototype.hasOwnProperty.call(registry, name) ? registry[name] : null;
+ }
+
+ NS.icons = {
+ registerIcon: registerIcon,
+ resolveIcon: resolveIcon
+ };
+
+ // Bundled glyphs (Material Symbols Outlined, Apache License 2.0, Google upstream - see NOTICE).
+ registerIcon("content_copy", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm160-240v-480 480Z\"/></svg>");
+ registerIcon("csv", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M230-360h120v-60H250v-120h100v-60H230q-17 0-28.5 11.5T190-560v160q0 17 11.5 28.5T230-360Zm156 0h120q17 0 28.5-11.5T546-400v-60q0-17-11.5-31.5T506-506h-60v-34h100v-60H426q-17 0-28.5 11.5T386-560v60q0 17 11.5 30.5T426-456h60v36H386v60Zm264 0h60l70-240h-60l-40 138-40-138h-60l70 240ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h64 [...]
+ registerIcon("table", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm240-240H200v160h240v-160Zm80 0v160h240v-160H520Zm-80-80v-160H200v160h240Zm80 0h240v-160H520v160ZM200-680h560v-80H200v80Z\"/></svg>");
+ registerIcon("picture_as_pdf", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M360-460h40v-80h40q17 0 28.5-11.5T480-580v-40q0-17-11.5-28.5T440-660h-80v200Zm40-120v-40h40v40h-40Zm120 120h80q17 0 28.5-11.5T640-500v-120q0-17-11.5-28.5T600-660h-80v200Zm40-40v-120h40v120h-40Zm120 40h40v-80h40v-40h-40v-40h40v-40h-80v200ZM320-240q-33 0-56.5-23.5T240-320v-480q0-33 23.5-56.5T320-880h480q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H320Zm0-80h480v-480H320v480ZM160-80q-33 0-5 [...]
+ registerIcon("refresh", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z\"/></svg>");
+ registerIcon("manage_search", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M80-200v-80h400v80H80Zm0-200v-80h200v80H80Zm0-200v-80h200v80H80Zm744 400L670-354q-24 17-52.5 25.5T560-320q-83 0-141.5-58.5T360-520q0-83 58.5-141.5T560-720q83 0 141.5 58.5T760-520q0 29-8.5 57.5T726-410l154 154-56 56ZM560-400q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Z\"/></svg>");
+ registerIcon("unfold_less", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"m356-160-56-56 180-180 180 180-56 56-124-124-124 124Zm124-404L300-744l56-56 124 124 124-124 56 56-180 180Z\"/></svg>");
+ registerIcon("tune", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z\"/></svg>");
+ registerIcon("first_page", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M240-240v-480h80v480h-80Zm440 0L440-480l240-240 56 56-184 184 184 184-56 56Z\"/></svg>");
+ registerIcon("chevron_left", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M560-240 320-480l240-240 56 56-184 184 184 184-56 56Z\"/></svg>");
+ registerIcon("chevron_right", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z\"/></svg>");
+ registerIcon("last_page", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"m280-240-56-56 184-184-184-184 56-56 240 240-240 240Zm360 0v-480h80v480h-80Z\"/></svg>");
+ registerIcon("filter_alt", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M440-160q-17 0-28.5-11.5T400-200v-240L168-736q-15-20-4.5-42t36.5-22h560q26 0 36.5 22t-4.5 42L560-440v240q0 17-11.5 28.5T520-160h-80Zm40-308 198-252H282l198 252Zm0 0Z\"/></svg>");
+ // Unified paging ribbon's page-size menu-button caret (visual-parity follow-up - see juneau-views.js's
+ // buildPageSizeMenu(...)); previously missing from this pass's original 13-glyph set.
+ registerIcon("expand_more", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z\"/></svg>");
+})();
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
new file mode 100644
index 0000000000..c2c341c6e9
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-pages.js - opt-in tabs/sub-tabs page runtime for the Apache Juneau rich-view toolkit (TODO-399 Phase C).
+ *
+ * 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.
+ *
+ * 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
+ * juneau-views.js's own VIEW_META handshake - performs a FAIL-LOUD contract-version check on PAGE_META's
+ * contractVersion (a DISTINCT check from the per-view VIEW_META handshake, which juneau-views.js's initTable(...)
+ * keeps running unchanged for each panel's view table - this runtime does not bypass or duplicate that one).
+ *
+ * Responsibilities (design doc §"Client page runtime"):
+ * 1. Deep-linkable initial tab: parse location.hash (#<pageId>/<tabId>[/<subtabId>]), falling back to the first
+ * tab/subtab when absent or unknown.
+ * 2. Hash-driven switching: tab/sub-tab links are plain <a href="#pageId/tabId[/subtabId]"> elements, so a click
+ * updates location.hash via the browser's native anchor behavior (back/forward + shareable, for free) - this
+ * runtime only needs a `hashchange` listener to reflect the new hash into the visible panel.
+ * 3. Lazy init on first activation: a panel's table[data-juneau-view] is inited (via juneau-views.js's now-public
+ * NS.init.initTable, idempotent) the first time its panel becomes visible; on every subsequent show,
+ * DataTables' `columns.adjust()` is called instead to fix any sizing deferred while the panel was
+ * `display:none` (DataTables mis-sizes columns initialized inside a hidden container).
+ *
+ * SECURITY: hash-derived tabId/subtabId values are NEVER written into innerHTML/selectors built from them - they
+ * are only compared (===) against existing elements' data-tab-id/data-subtab-id ATTRIBUTE VALUES (read via
+ * getAttribute, not interpolated into a querySelector string), so a crafted location.hash cannot inject markup or
+ * break out of a CSS selector.
+ *
+ * Everything in the "PURE LOGIC LAYER" is DOM-free (plain data in, plain data out) so it is unit-checkable
+ * independent of a browser (mirrors the juneau-views.js/juneau-ribbon.js Option-B split); the
+ * "DOM BINDING LAYER" is the thin shim that scans, parses, and wires hashchange + lazy init.
+ */
+(function () {
+ "use strict";
+
+ // 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";
+
+ var NS = window.JuneauViews = window.JuneauViews || {};
+
+ // ==================================================================================================================
+ // PURE LOGIC LAYER (no DOM)
+ // ==================================================================================================================
+
+ /**
+ * Parses a `location.hash`-shaped string into `{pageId, tabId, subtabId}`. Accepts the hash with or without its
+ * leading "#". Returns `null` for an empty/absent hash (caller falls back to the first tab/subtab). Segments
+ * beyond the third are ignored; a missing trailing segment yields `null` for that field (never `""`).
+ */
+ function parseHash(hash) {
+ var h = String(hash || "").replace(/^#/, "");
+ if (!h) return null;
+ var parts = h.split("/");
+ return {
+ pageId: parts[0] || null,
+ tabId: parts[1] || null,
+ subtabId: parts[2] || null
+ };
+ }
+
+ /** 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++)
+ if (list[i].id === id) return list[i];
+ return null;
+ }
+
+ /**
+ * Resolves the tab/sub-tab that should be active for a given PAGE_META + `location.hash`, falling back to the
+ * first tab (and, when that tab has sub-tabs, its first sub-tab) whenever the hash is absent, targets a
+ * different page, or names an id that no longer exists (design doc §"Client page runtime" pt.1). Returns
+ * `{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) || [];
+ if (!tabs.length) return null;
+
+ var parsed = parseHash(hash);
+ var 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 };
+ if (tab.subtabs && tab.subtabs.length) {
+ var sub = (parsed && parsed.subtabId) ? findById(tab.subtabs, parsed.subtabId) : null;
+ if (!sub) sub = tab.subtabs[0];
+ result.subtabId = sub.id;
+ }
+ return result;
+ }
+
+ /** 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;
+ if (subtabId != null) h += "/" + subtabId;
+ return h;
+ }
+
+ // ==================================================================================================================
+ // DOM BINDING LAYER (thin shim)
+ // ==================================================================================================================
+
+ function warn(msg) { if (window.console && console.warn) console.warn(msg); }
+ function error(msg) { if (window.console && console.error) console.error(msg); }
+
+ /** 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");
+ b.className = "jc-page-error";
+ b.textContent = message;
+ root.insertBefore(b, root.firstChild);
+ }
+
+ /** Whether `panel`'s data-panel-tab/data-panel-subtab attributes match the resolved active (tabId, subtabId). */
+ function panelMatches(panel, tabId, subtabId) {
+ if (panel.getAttribute("data-panel-tab") !== tabId) return false;
+ var panelSubtabId = panel.getAttribute("data-panel-subtab");
+ return subtabId != null ? panelSubtabId === subtabId : !panelSubtabId;
+ }
+
+ /** Lazily inits (or, if already a DataTable, columns.adjust()s) every view table inside a just-shown panel. */
+ function activatePanelViews(panel) {
+ var tables = panel.querySelectorAll("table[data-juneau-view]");
+ Array.prototype.forEach.call(tables, function (t) {
+ var $ = window.jQuery;
+ if ($ && $.fn && $.fn.dataTable && $.fn.dataTable.isDataTable(t)) {
+ $(t).DataTable().columns.adjust();
+ } else if (NS.init && 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 '" +
+ t.getAttribute("data-juneau-view") + "'.");
+ }
+ });
+ }
+
+ /** 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");
+ 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");
+ Array.prototype.forEach.call(subtabs, function (el) {
+ var 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(".jc-panel, .jc-subpanel");
+ Array.prototype.forEach.call(panels, function (p) {
+ var 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);
+ if (!sidecar) { error("Juneau page '" + id + "': missing PAGE_META sidecar; refusing to init."); return; }
+
+ var pageMeta;
+ try {
+ pageMeta = JSON.parse(sidecar.textContent);
+ } catch (e) {
+ error("Juneau page '" + id + "': malformed PAGE_META sidecar; refusing to init.");
+ renderBanner(root, "Juneau page '" + id + "': malformed configuration.");
+ return;
+ }
+
+ if (pageMeta.contractVersion !== JUNEAU_PAGE_CONTRACT_VERSION) {
+ var 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);
+ return; // refuse to init rather than silently mis-render
+ }
+
+ function apply() {
+ var active = resolveInitial(pageMeta, window.location.hash);
+ if (active) showActive(root, active.tabId, active.subtabId);
+ }
+
+ window.addEventListener("hashchange", apply);
+ apply();
+ }
+
+ function initAllPages() {
+ var pages = document.querySelectorAll("[data-juneau-page]");
+ Array.prototype.forEach.call(pages, function (p) { initPage(p); });
+ }
+
+ // ==================================================================================================================
+ // PUBLIC API + bootstrap
+ // ==================================================================================================================
+
+ NS.pages = {
+ CONTRACT_VERSION: JUNEAU_PAGE_CONTRACT_VERSION,
+ parseHash: parseHash,
+ findById: findById,
+ resolveInitial: resolveInitial,
+ hashFor: hashFor,
+ initAllPages: initAllPages
+ };
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initAllPages);
+ } else {
+ initAllPages();
+ }
+})();
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
new file mode 100644
index 0000000000..80e6c409dc
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-renders.js - dependency-free cell-renderer registry for the Apache Juneau rich-view toolkit (Task B.6).
+ *
+ * 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:
+ *
+ * { display(cellData, rowData, meta), sort(...), filter(...), type(...), class(cellData, rowData, meta) }
+ *
+ * In the MVP SERVER data mode only `display` + `class` are live - the server does sort/filter/type via
+ * DataTablesQueryProtocol, so those facets are inert (they become live only in client-side mode). The registry
+ * contract still defines all four for forward-compatibility.
+ *
+ * Everything in the "PURE LOGIC LAYER" below is a pure (cellData, rowData, meta) -> string/class function with NO
+ * dependency on jQuery, DataTables, or the DOM - so it is unit-checkable (Option B: jsdom + node:test) and
+ * Option-B-portable without pulling in the caller-provided libs. Generic renderers use only built-in
+ * Intl.DateTimeFormat / Date - NO date library (no moment/dayjs/luxon), bundled or caller-provided.
+ *
+ * The `tag` renderer emits the shared `.tag.<domain>.<value>` class-name contract that console-ui OWNS; this module
+ * ships only the dependency-free base `.tag` chip (juneau-views.css, neutral/no colors) and takes NO dependency on
+ * console-ui - its palette themes the same classes when present.
+ */
+(function () {
+ "use strict";
+
+ var NS = window.JuneauViews = window.JuneauViews || {};
+
+ // ==================================================================================================================
+ // PURE LOGIC LAYER (no DOM, no jQuery, no DataTables)
+ // ==================================================================================================================
+
+ var registry = NS._renderers = NS._renderers || {};
+
+ /**
+ * Registers (or overrides) a renderer under `id`. `def` is `{display?, sort?, filter?, type?, class?}`; a bare
+ * function is treated as the `display` facet. Apps extend the registry via window.JuneauViews.registerRenderer.
+ */
+ function registerRenderer(id, def) {
+ registry[id] = (typeof def === "function") ? { display: def } : (def || {});
+ return registry[id];
+ }
+
+ /** 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;
+ }
+
+ /**
+ * Parses a render reference into the canonical `{id, meta?}` form (VIEW_META §6.6). Accepts either the already
+ * canonical object, or the `"id:field"` string sugar where everything after the FIRST colon becomes meta.field.
+ */
+ function parseRenderId(spec) {
+ if (spec == null) return null;
+ if (typeof spec === "object") return spec;
+ var i = spec.indexOf(":");
+ if (i < 0) return { id: spec };
+ return { id: spec.substring(0, i), meta: { field: spec.substring(i + 1) } };
+ }
+
+ // --- escaping helpers (pure) ---------------------------------------------------------------------------------
+
+ function escHtml(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function escAttr(s) {
+ return escHtml(s);
+ }
+
+ /** 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;
+ return v == null ? "" : encodeURIComponent(String(v));
+ });
+ }
+
+ /**
+ * Normalizes a raw string to the `.tag.<domain>.<value>` CSS token, mirroring console-ui's
+ * `org.apache.juneau.rest.server.console.Tag#normalize` lowercasing step exactly: plain (locale-insensitive)
+ * lowercasing, equivalent to Java's `raw.toLowerCase(Locale.ROOT)`. `Tag#normalize` then fail-closed REJECTs
+ * (`IllegalArgumentException`) unless the lowercased result full-string-matches `^[a-z0-9_-]+$`; this renderer
+ * cannot reject a data value outright (a cell must always render *something*), so instead of throwing, any
+ * run of characters outside `[a-z0-9_-]` (e.g. whitespace) collapses to a single "-", with leading/trailing
+ * hyphens trimmed - keeping the emitted token inside the same `^[a-z0-9_-]+$` shape the server enforces.
+ * KEEP THIS IN SYNC WITH `Tag#normalize` / `TagHtmlRender#getContent`'s token algorithm.
+ */
+ function normalizeTagToken(raw) {
+ return String(raw)
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ }
+
+ /** 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;
+ }
+
+ // --- generic renderers (pure) --------------------------------------------------------------------------------
+ // SERVER mode: display + class only. Unknown-id policy (skip + warn) lives in the consumer (juneau-views.js).
+
+ registerRenderer("date", {
+ display: function (cellData) {
+ var 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; }
+ });
+
+ registerRenderer("datetime", {
+ display: function (cellData) {
+ var 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; }
+ });
+
+ registerRenderer("ts-zulu", {
+ // Absolute UTC ("Zulu") ISO-8601 timestamp - stable, locale-independent, no date library.
+ display: function (cellData) {
+ var 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; }
+ });
+
+ registerRenderer("bool", {
+ display: function (cellData) {
+ if (cellData == null || cellData === "") return "";
+ var truthy = (cellData === true || cellData === 1 || cellData === "true" || cellData === "1" || cellData === "yes");
+ return truthy ? "Yes" : "No";
+ }
+ });
+
+ registerRenderer("linked", {
+ // 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;
+ if (!href) return escHtml(text);
+ return '<a href="' + escAttr(interpolateHref(href, rowData)) + '">' + escHtml(text) + "</a>";
+ }
+ });
+
+ 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);
+ return '<span title="' + escAttr(s) + '">' + escHtml(s.substring(0, max)) + "\u2026</span>";
+ }
+ });
+
+ registerRenderer("json", {
+ display: function (cellData) {
+ if (cellData == null) return "";
+ var s;
+ try { s = JSON.stringify(cellData); } catch (e) { s = String(cellData); }
+ return "<code>" + escHtml(s) + "</code>";
+ }
+ });
+
+ 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;
+ return escHtml(n.toFixed(places >= 0 ? places : 2));
+ },
+ sort: function (cellData) { var n = Number(cellData); return isNaN(n) ? cellData : n; }
+ });
+
+ registerRenderer("tag", {
+ // Emits the shared `.tag.<domain>.<value>` chip; <domain> = meta.field (the column key), <value> = the
+ // cell. Both CSS tokens are normalized via normalizeTagToken() to mirror console-ui's
+ // Tag#normalize/TagHtmlRender token algorithm (lowercase, non-[a-z0-9_-] runs -> "-") so themed
+ // 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 : "");
+ return '<span class="' + escAttr(cls) + '">' + escHtml(value) + "</span>";
+ },
+ "class": function () { return "tag-cell"; }
+ });
+
+ // ==================================================================================================================
+ // PUBLIC API
+ // ==================================================================================================================
+
+ NS.registerRenderer = registerRenderer;
+ NS.resolveRenderer = resolveRenderer;
+ NS.parseRenderId = parseRenderId;
+ // Exposed for reuse by the initializer + Option-B unit tests.
+ NS._render = NS._render || {};
+ NS._render.escHtml = escHtml;
+ NS._render.escAttr = escAttr;
+ NS._render.interpolateHref = interpolateHref;
+ NS._render.toDate = toDate;
+ NS._render.normalizeTagToken = normalizeTagToken;
+})();
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
new file mode 100644
index 0000000000..fa0cb7b529
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
@@ -0,0 +1,354 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-ribbon.js - ribbon/toolbar runtime for the Apache Juneau rich-view toolkit (Task B.7).
+ *
+ * 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
+ * (with persisted state), and divider.
+ *
+ * CONSISTENCY REQUIREMENT (mirrors the server): when a column-scoped `option`/`optionGroup` toggle is ACTIVE, the
+ * client contributes the SAME `columns[N][search][value]=<value>` request param that the server-side
+ * RibbonAction.toQueryParams(ViewDef) produces (custom `param` options contribute `param=value` verbatim). The
+ * server maps unconditionally (it has no notion of "active"); the CLIENT owns active state, so ONLY active toggles
+ * contribute here. ribbonToQueryParams(viewDef, activeState) below is the pure counterpart of the Java mapping and
+ * shares its fixtures so the two implementations cannot drift.
+ *
+ * Everything in the "PURE LOGIC LAYER" is DOM/jQuery/DataTables-free (feature-detection takes its environment as an
+ * argument), so it is unit-checkable (Option B) and Option-B-portable. The "DOM/JQUERY BINDING LAYER" is the thin
+ * shim that renders the toolbar and wires it to a DataTables instance.
+ */
+(function () {
+ "use strict";
+
+ var NS = window.JuneauViews = window.JuneauViews || {};
+
+ // ==================================================================================================================
+ // PURE LOGIC LAYER (no DOM, no jQuery, no DataTables)
+ // ==================================================================================================================
+
+ /** 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++)
+ if (cols[i].data === columnKey) return i;
+ return -1;
+ }
+
+ /**
+ * Maps ONE option/opt to the {name, value} request param it contributes, or null. Column-scoped options resolve
+ * to `columns[<index>][search][value]`; custom-param options contribute `param=value` verbatim; a valueless (or
+ * column+param-less) option contributes nothing. Byte-for-byte identical to the Java addOptionParam(...).
+ */
+ function optionParam(viewDef, opt) {
+ if (opt == null || opt.value == null) return null;
+ if (opt.column != null) {
+ var idx = columnIndex(viewDef, opt.column);
+ if (idx < 0) return null;
+ return { name: "columns[" + idx + "][search][value]", value: opt.value };
+ }
+ if (opt.param != null) return { name: opt.param, value: opt.value };
+ return null;
+ }
+
+ /**
+ * The pure counterpart of RibbonAction.toQueryParams(ViewDef): the request params the ribbon contributes given the
+ * client's ACTIVE toggle state. `activeState` is a map keyed by option/group id:
+ * - a top-level `option` contributes iff activeState[option.id] is truthy;
+ * - an `optionGroup` contributes its member whose id === activeState[group.id] (the selected radio value).
+ * refresh/columnSearchToggle/divider/export are not query-contributing and are skipped.
+ */
+ function ribbonToQueryParams(viewDef, activeState) {
+ var out = {};
+ var state = activeState || {};
+ (viewDef.ribbon || []).forEach(function (a) {
+ if (a.type === "option") {
+ if (state[a.id]) {
+ var p = optionParam(viewDef, a);
+ if (p) out[p.name] = p.value;
+ }
+ } else if (a.type === "optionGroup" && a.options) {
+ var selected = state[a.id];
+ a.options.forEach(function (o) {
+ if (o.id === selected) {
+ var p = optionParam(viewDef, o);
+ if (p) out[p.name] = p.value;
+ }
+ });
+ }
+ });
+ return out;
+ }
+
+ /**
+ * Feature-detects the caller-provided export extensions in the given environment (defaults to window). Returns
+ * `{buttons, jszip, pdfmake}` booleans. Export degrades gracefully: with no DataTables Buttons, no export button
+ * is offered at all; excel needs JSZip; pdf needs pdfMake.
+ */
+ function detectExportFeatures(win) {
+ win = win || (typeof window !== "undefined" ? window : {});
+ var $ = win.jQuery;
+ var hasButtons = !!($ && $.fn && $.fn.dataTable && $.fn.dataTable.Buttons);
+ return { buttons: hasButtons, jszip: !!win.JSZip, pdfmake: !!win.pdfMake };
+ }
+
+ /**
+ * Given an `export` action and detected features, returns the button ids that should actually be offered. With no
+ * Buttons extension the result is empty (graceful degrade); optional excel/pdf are included only when their extra
+ * dep is present, otherwise omitted.
+ */
+ function resolveExportButtons(action, features) {
+ var out = [];
+ if (!features || !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);
+ // else: dep absent - omit/grey (feature-detected)
+ });
+ return out;
+ }
+
+ /** localStorage key for a persisted ribbon toggle (VIEW_META §6.8: juneau.view.<viewId>.ribbon.<optionId>). */
+ function ribbonStorageKey(viewId, optionId) {
+ return "juneau.view." + viewId + ".ribbon." + optionId;
+ }
+
+ /**
+ * 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"
+ * affordance; inert until that action type exists.
+ */
+ var DEFAULT_ICONS = {
+ copy: "content_copy", csv: "csv", excel: "table", pdf: "picture_as_pdf",
+ refresh: "refresh", columnSearchToggle: "manage_search", collapse: "unfold_less"
+ };
+
+ /**
+ * Resolves the icon NAME (not markup) for a ribbon button - pure, DOM-free (§4.A). An explicit `symbol` on the
+ * 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).
+ */
+ 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];
+ return "tune";
+ }
+
+ // ==================================================================================================================
+ // DOM / JQUERY BINDING LAYER (thin shim; not exercised by the pure unit tests)
+ // ==================================================================================================================
+
+ function safeStorage() {
+ try { return window.localStorage; } catch (e) { return null; }
+ }
+
+ function loadPersistedState(viewDef) {
+ var state = {};
+ var 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));
+ if (raw != null) state[a.id] = (raw === "true");
+ } else if (a.type === "optionGroup" && a.persist) {
+ var sel = store.getItem(ribbonStorageKey(viewDef.id, a.id));
+ if (sel != null) state[a.id] = sel;
+ }
+ });
+ return state;
+ }
+
+ function persist(viewDef, id, value) {
+ var store = safeStorage();
+ if (store) store.setItem(ribbonStorageKey(viewDef.id, id), String(value));
+ }
+
+ /**
+ * Builds the ribbon toolbar element for a view and wires it to its DataTables instance. Returns the toolbar
+ * element (or null when there is no ribbon). `ctx` carries { table, dataTable (the DT api), activeState, redraw,
+ * columnSearchOn, onColumnSearchToggle }.
+ *
+ * <p>Adjacent actions sharing a non-null {@code group} id (visual-parity design doc §4.A, item 2/5) are
+ * clustered into ONE segmented {@code .juneau-view-ribbon-group} wrapper (shared borders, rounded only on the
+ * outer ends - see juneau-views.css) via the local {@code place(el, groupId)} helper below. An {@code export}
+ * action's own resolved buttons are always clustered this way even without an explicit {@code group} (one
+ * 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.
+ */
+ function buildRibbon(viewDef, ctx) {
+ var actions = viewDef.ribbon || [];
+ if (!actions.length) return null;
+
+ var $ = window.jQuery;
+ var features = detectExportFeatures(window);
+ var 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
+ function place(el, groupId) {
+ if (groupId == null) {
+ openGroup = null;
+ bar.appendChild(el);
+ return;
+ }
+ if (!openGroup || openGroup.id !== groupId) {
+ var wrap = document.createElement("span");
+ wrap.className = "juneau-view-ribbon-group";
+ bar.appendChild(wrap);
+ openGroup = { id: groupId, el: wrap };
+ }
+ openGroup.el.appendChild(el);
+ }
+
+ actions.forEach(function (a, idx) {
+ if (a.type === "divider") {
+ openGroup = null;
+ var 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) {
+ 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);
+ ids.forEach(function (id) {
+ place(button(id, resolveButtonIcon(null, id), function () {
+ ctx.dataTable.button(id).trigger();
+ }), exportGroupId);
+ });
+ } catch (e) { /* Buttons present but init failed - degrade silently */ }
+ }
+ return;
+ }
+ if (a.type === "refresh") {
+ place(button(a.title || "Refresh", resolveButtonIcon(a, "refresh"), function () { ctx.redraw(); }), a.group || null);
+ return;
+ }
+ if (a.type === "columnSearchToggle") {
+ var 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");
+ place(csBtn, a.group || null);
+ return;
+ }
+ if (a.type === "option") {
+ place(optionToggle(viewDef, a, ctx), a.group || null);
+ return;
+ }
+ if (a.type === "optionGroup") {
+ openGroup = null;
+ bar.appendChild(optionGroup(viewDef, a, ctx));
+ }
+ });
+ return bar;
+ }
+
+ /**
+ * Builds one icon-only 32px ribbon/pill button (visual-parity design doc §4.A/§2.2). No visible text label -
+ * `label` becomes the button's native `title` (tooltip) and `aria-label` (screen-reader text) only. Resolves
+ * `iconName` via the icon registry (`NS.icons.resolveIcon`); the markup assigned to `innerHTML` is ALWAYS a
+ * static, first-party, build-time-authored SVG string from that registry - never request-/app-supplied - so
+ * this is not an HTML-injection sink (design doc §7). An unregistered icon name falls back to rendering the
+ * label as text (mirrors the "unknown render id -> warn once, fall back to raw value" convention already
+ * documented for juneau-renders.js).
+ */
+ function button(label, iconName, onClick) {
+ var 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;
+ if (markup != null) {
+ b.innerHTML = markup;
+ } else {
+ b.textContent = b.title;
+ }
+ b.addEventListener("click", onClick);
+ return b;
+ }
+
+ function optionToggle(viewDef, action, ctx) {
+ var 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]);
+ ctx.redraw();
+ });
+ b.setAttribute("aria-pressed", ctx.activeState[action.id] ? "true" : "false");
+ return b;
+ }
+
+ function optionGroup(viewDef, group, ctx) {
+ var 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 () {
+ 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();
+ });
+ wrap.appendChild(b);
+ });
+ return wrap;
+ }
+
+ /**
+ * Flips the shared per-column-search-visibility flag on `ctx` and notifies the DOM binding layer (juneau-
+ * views.js's initTable(...)) so it can show/hide the per-column search row it owns. Returns the new state so
+ * the caller (the columnSearchToggle button's click handler above) can reflect it in `aria-pressed` - this
+ * function itself never touches a DOM node's attributes (kept callable/testable without a live button element).
+ */
+ function toggleColumnSearch(viewDef, ctx) {
+ ctx.columnSearchOn = !ctx.columnSearchOn;
+ if (ctx.onColumnSearchToggle) ctx.onColumnSearchToggle(ctx.columnSearchOn);
+ return ctx.columnSearchOn;
+ }
+
+ // ==================================================================================================================
+ // PUBLIC API
+ // ==================================================================================================================
+
+ NS.ribbon = {
+ // pure
+ columnIndex: columnIndex,
+ optionParam: optionParam,
+ ribbonToQueryParams: ribbonToQueryParams,
+ detectExportFeatures: detectExportFeatures,
+ resolveExportButtons: resolveExportButtons,
+ ribbonStorageKey: ribbonStorageKey,
+ resolveButtonIcon: resolveButtonIcon,
+ // binding
+ loadPersistedState: loadPersistedState,
+ build: buildRibbon
+ };
+})();
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
new file mode 100644
index 0000000000..40236a1e11
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -0,0 +1,359 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-views.css - base ".tag" chip for the Apache Juneau rich-view toolkit (Task B.6).
+ *
+ * DEPENDENCY-FREE + NEUTRAL: this file styles ONLY the shape of the shared ".tag.<domain>.<value>" chip contract -
+ * padding, border-radius, inline layout - and deliberately ships NO colors (no color/background/border-color
+ * 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 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).
+ */
+
+/* Base chip - neutral shape/padding/border-radius, NO colors. */
+.tag {
+ display: inline-block;
+ padding: 0.15em 0.6em;
+ border: 1px solid; /* border-color falls back to currentColor - no palette color set here */
+ border-radius: 0.75em;
+ font-size: 0.85em;
+ line-height: 1.4;
+ white-space: nowrap;
+ vertical-align: baseline;
+}
+
+/* Fail-loud contract-version-mismatch banner (B.8) - neutral, no palette color. */
+.juneau-view-error {
+ display: block;
+ padding: 0.5em 0.75em;
+ margin: 0.25em 0;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.25em;
+ font-weight: bold;
+}
+
+/*
+ * 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
+ * (console-ui's chrome.css themes the SAME selectors with --jc-* colors, mirroring the ".tag"/ribbon reconciliation
+ * above). Selectors list BOTH `table[data-juneau-view]` (the marker attribute present from first paint) and
+ * `table.dataTable` (the class DataTables' own init ADDS to that same element - it never removes the attribute,
+ * so both describe the identical element pre/post-init) so the rule applies whether or not DataTables has run
+ * yet. Child-combinator (`>`) selectors deliberately mirror the vendored dataTables.dataTables.css structure
+ * (`table.dataTable>thead>tr>th`, `table.dataTable>tbody>tr>td`) so this rule's specificity TIES the vendored
+ * one instead of losing to it; this file is served AFTER the vendored DataTables CSS in every juneau-views page's
+ * <head>, so the tie resolves in our favor by cascade order.
+ */
+table[data-juneau-view],
+table.dataTable {
+ font-size: 0.8125rem;
+}
+
+table[data-juneau-view] > thead > tr > th,
+table[data-juneau-view] > thead > tr > td,
+table.dataTable > thead > tr > th,
+table.dataTable > thead > tr > td {
+ padding: 8px 10px;
+ font-weight: 600;
+ text-align: left;
+ border-bottom-width: 1px;
+ border-bottom-style: solid; /* color falls back to currentColor - no palette color set here */
+}
+
+table[data-juneau-view] > tbody > tr > th,
+table[data-juneau-view] > tbody > tr > td,
+table.dataTable > tbody > tr > th,
+table.dataTable > tbody > tr > td {
+ padding: 6px 10px;
+ vertical-align: middle;
+ border-bottom-width: 1px;
+ border-bottom-style: solid; /* color falls back to currentColor - no palette color set here */
+}
+
+/*
+ * DataTables ribbon visual-parity pass (design doc §4.A) - neutral 32px icon-only ribbon-button shape. NEW rule
+ * (not a rewrite - the module shipped no ".juneau-view-ribbon*" CSS before this pass; buttons rendered with bare
+ * browser-default styling). NEUTRAL - no palette color (border/color fall back to currentColor); console-ui's
+ * chrome.css layers the themed hover/active accent over these same classes.
+ *
+ * ".juneau-view-ribbon" is the CONTAINER buildRibbon(...) creates (juneau-ribbon.js) - it must lay its buttons out
+ * as a horizontal row itself; ".juneau-view-ribbon-btn" below only shapes each individual button (a per-button
+ * `display: flex` centers that button's own icon, it does NOT make sibling buttons flow horizontally - omitting a
+ * container rule left every button stacking vertically, one per line).
+ */
+.juneau-view-ribbon {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.25em;
+}
+
+.juneau-view-ribbon-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.25em;
+ background: transparent;
+ cursor: pointer;
+}
+
+.juneau-view-ribbon-btn svg { width: 18px; height: 18px; fill: currentColor; }
+
+.juneau-view-ribbon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+/* Neutral shape for an ACTIVE toggle (`option`/`optionGroup` member button's aria-pressed="true") - a thicker
+ * border only (no palette color here); console-ui's chrome.css layers the themed background/color accent over
+ * this same attribute selector, exactly like it already does for :hover/:active. Without ANY visual affordance
+ * here, a toggled-on filter (e.g. a column-scoped `option` persisted to localStorage) is invisible - indistinguishable
+ * from every other unpressed button - so it can silently keep filtering out all rows after being clicked once. */
+.juneau-view-ribbon-btn[aria-pressed="true"] { border-width: 2px; }
+
+/*
+ * Segmented ribbon group (design doc §4.A, item 2/5 - IRS parity): a "single ribbon" of adjacent buttons that
+ * share a border (no double borders) with rounding only on the group's outer ends. NEUTRAL - shape only (radius/
+ * margin-collapse); console-ui's chrome.css themes the member buttons' colors exactly like a standalone button
+ * (same ".juneau-view-ribbon-btn" class, so :hover/:active/[aria-pressed] all already apply unchanged). The
+ * negative margin-left overlaps each member's left border onto its predecessor's right border (collapsing the
+ * pair into one hairline); a raised z-index on hover/focus/pressed keeps that member's own border/background on
+ * top of its neighbors' overlapping edges.
+ */
+.juneau-view-ribbon-group { display: inline-flex; }
+
+.juneau-view-ribbon-group .juneau-view-ribbon-btn {
+ border-radius: 0;
+ margin-left: -1px;
+ position: relative;
+}
+
+.juneau-view-ribbon-group .juneau-view-ribbon-btn:first-child { margin-left: 0; border-radius: 0.25em 0 0 0.25em; }
+.juneau-view-ribbon-group .juneau-view-ribbon-btn:last-child { border-radius: 0 0.25em 0.25em 0; }
+
+.juneau-view-ribbon-group .juneau-view-ribbon-btn:hover,
+.juneau-view-ribbon-group .juneau-view-ribbon-btn:focus,
+.juneau-view-ribbon-group .juneau-view-ribbon-btn[aria-pressed="true"] { z-index: 1; }
+
+/*
+ * Unified paging ribbon (visual-parity follow-up) - hides BOTH DataTables major-version's native length-select/
+ * pagination class-name generations (DT1.x ".dataTables_length"/".dataTables_paginate", DT2.x ".dt-length"/
+ * ".dt-paging") and ships the first-party ribbon's neutral shape: `[First] [Prev] [<range> + page-size menu]
+ * [Next] [Last]` - a SINGLE segmented control (no separate page-size <select>, no second paging control
+ * elsewhere). NEUTRAL - no palette color; console-ui's chrome.css layers the themed accent/disabled treatment
+ * over these same classes. The native entry-count text (".dataTables_info"/".dt-info") needs no hide rule here:
+ * `buildOptions(...)` sets DataTables' own `info: false` (juneau-views.js) so that node is never created in the
+ * first place - disabled at the source rather than hidden after the fact.
+ */
+.dataTables_length, .dt-length, .dataTables_paginate, .dt-paging { display: none; }
+
+.juneau-view-pagingpill {
+ display: inline-flex;
+ align-items: stretch;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.25em;
+}
+
+/* Corner-rounding on the outer segments only (no `overflow: hidden` on the pill itself - that would clip the
+ page-size menu's popup, which is positioned absolute relative to a segment INSIDE the pill). */
+.juneau-view-pagingpill > *:first-child { border-radius: 0.25em 0 0 0.25em; }
+.juneau-view-pagingpill > *:last-child { border-radius: 0 0.25em 0.25em 0; }
+
+.juneau-view-pagingpill-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: none;
+ border-right: 1px solid; /* currentColor - segment divider; last-child override below */
+ background: transparent;
+ cursor: pointer;
+}
+.juneau-view-pagingpill-btn:last-child { border-right: none; }
+.juneau-view-pagingpill-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.juneau-view-pagingpill-btn svg { width: 18px; height: 18px; fill: currentColor; }
+
+/*
+ * The pill's central segment (visual-parity follow-up): BOTH the compact "n-n of n" range summary AND the
+ * page-size menu button that replaced the old standalone <select> - a WAI-ARIA menu-button (`.juneau-view-
+ * pagingpill-menubtn`, `aria-haspopup="listbox"`) whose popup (`.juneau-view-pagingpill-menu`, `role="listbox"`)
+ * lists the page-size options. NEUTRAL - shape only; console-ui's chrome.css themes text/accent/background.
+ */
+.juneau-view-pagingpill-menuwrap {
+ position: relative;
+ display: flex;
+ border-right: 1px solid; /* currentColor - segment divider */
+}
+
+.juneau-view-pagingpill-menubtn {
+ display: flex;
+ align-items: center;
+ gap: 0.35em;
+ height: 32px;
+ padding: 0 0.6em;
+ border: none;
+ background: transparent;
+ font-size: 0.8125rem;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.juneau-view-pagingpill-info { white-space: nowrap; }
+
+.juneau-view-pagingpill-caret svg { width: 14px; height: 14px; fill: currentColor; }
+
+/* The popup itself - absolutely positioned below the menu button; `[hidden]` (set/cleared by juneau-views.js)
+ is enough to show/hide it, so no explicit `display` toggle rule is needed here. */
+.juneau-view-pagingpill-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 10;
+ min-width: 100%;
+ margin: 0.2em 0 0;
+ padding: 0.25em 0;
+ list-style: none;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.25em;
+}
+
+.juneau-view-pagingpill-menu-option {
+ padding: 0.3em 1.75em 0.3em 0.75em;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.juneau-view-pagingpill-menu-option[aria-selected="true"] { font-weight: bold; }
+
+/* A plain checkmark glyph (not an icon-registry glyph - a bare CSS content string) indicating the current
+ page-size selection, mirroring the affordance a native <select> gives its chosen <option> for free. */
+.juneau-view-pagingpill-menu-option[aria-selected="true"]::after {
+ content: "\2713";
+ float: right;
+ margin-left: 0.75em;
+}
+
+/*
+ * Single unified top-toolbar row (IRS reference layout): a LEFT cluster (page-size + paging nav pill) and a RIGHT
+ * cluster (search, ribbon groups, refresh) laid out with `space-between` so the right cluster stays right-aligned
+ * - built once by `buildToolbarRow(...)` (juneau-views.js), which assembles both clusters above the table.
+ */
+.juneau-view-toolbar-row {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5em;
+ margin: 0 0 0.5em 0;
+}
+
+.juneau-view-toolbar-left,
+.juneau-view-toolbar-right {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5em;
+}
+
+.juneau-view-ribbon-divider {
+ display: inline-block;
+ width: 0.5em; /* pure spacing only; no visible glyph. Stays in the DOM (unchanged JS branch). */
+}
+
+/*
+ * 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
+ * button. NEUTRAL - shape only; console-ui's chrome.css themes the input's border/background/focus colors.
+ */
+.juneau-view-columnsearch-row > th {
+ padding: 4px 8px;
+ font-weight: 400;
+}
+
+.juneau-view-columnsearch-input {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 3px 6px;
+ font-size: 0.8125rem;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.2em;
+}
+
+/*
+ * 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.
+ */
+.jc-tab-bar,
+.jc-subtab-bar {
+ display: flex;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ white-space: nowrap;
+ gap: 0.5em;
+ margin: 0 0 0.5em 0;
+}
+
+.jc-tab,
+.jc-subtab {
+ flex: 0 0 auto;
+ display: inline-block;
+ padding: 0.35em 0.75em;
+ text-decoration: none;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.25em;
+ cursor: pointer;
+}
+
+.jc-tab-active,
+.jc-subtab-active {
+ font-weight: bold;
+ border-bottom-width: 3px;
+}
+
+/* Panels are hidden by default; juneau-pages.js toggles ".jc-active" on the one panel that should be visible. */
+.jc-panel,
+.jc-subpanel {
+ display: none;
+}
+
+.jc-panel.jc-active,
+.jc-subpanel.jc-active {
+ display: block;
+}
+
+/* Fail-loud PAGE_META contract-version-mismatch banner - neutral, no palette color (mirrors .juneau-view-error). */
+.jc-page-error {
+ display: block;
+ padding: 0.5em 0.75em;
+ margin: 0.25em 0;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.25em;
+ font-weight: bold;
+}
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
new file mode 100644
index 0000000000..020059cd45
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -0,0 +1,651 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-views.js - client initializer for the Apache Juneau rich-view toolkit (Task B.8).
+ *
+ * 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 -
+ * performs a FAIL-LOUD contract-version handshake: if the sidecar's `contractVersion` differs from the baked-in
+ * JUNEAU_VIEW_CONTRACT_VERSION, it console.errors, renders a visible in-table banner, and REFUSES to init (rather
+ * than silently mis-rendering). Otherwise it builds the DataTables opts (columns[].render from the renderer
+ * registry, serverSide/ajax per dataMode, order from defaultOrder field->index resolution, rowClassRules applied in
+ * createdRow) and calls $(table).DataTable(opts), then wires the ribbon.
+ *
+ * The DataTables library itself (jQuery + DataTables JS/CSS) is NOT bundled - its license is not an ASF category-A
+ * license - so it stays caller-provided (CDN or self-hosted). The distinct `data-juneau-view` marker guarantees no
+ * collision with juneau-datatables.js (which only touches data-juneau-datatable).
+ *
+ * Everything in the "PURE LOGIC LAYER" is DOM/jQuery/DataTables-free (plain data in, plain data out), per the
+ * Option-B architecture check; the "DOM/JQUERY BINDING LAYER" is the thin shim that scans, parses, and binds.
+ */
+(function () {
+ "use strict";
+
+ // 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";
+
+ var NS = window.JuneauViews = window.JuneauViews || {};
+ NS.CONTRACT_VERSION = JUNEAU_VIEW_CONTRACT_VERSION;
+
+ // ==================================================================================================================
+ // PURE LOGIC LAYER (no DOM, no jQuery, no DataTables)
+ // ==================================================================================================================
+
+ /**
+ * Shared page-size vocabulary (visual-parity design doc §4.B) - a client-side default for this pass (not a new
+ * 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 = [
+ { value: 25, label: "25 rows" },
+ { value: 100, label: "100 rows" },
+ { value: -1, label: "All rows" }
+ ];
+
+ /** 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++)
+ if (cols[i].data === dataKey) return i;
+ return -1;
+ }
+
+ /**
+ * Resolves `defaultOrder` [{data,dir}] to DataTables' positional `order` [[colIndex, dir]] by field name (m2) -
+ * indices are not pinned server-side, so client-side column reorder stays correct. Unknown fields are skipped.
+ */
+ function resolveOrder(viewDef) {
+ var out = [];
+ (viewDef.defaultOrder || []).forEach(function (e) {
+ var idx = columnIndexOf(viewDef, e.data);
+ if (idx >= 0) out.push([idx, e.dir]);
+ });
+ return out;
+ }
+
+ /** Loose value equality that also matches boolean true vs "true", 1 vs "1", etc. (JSON type coercion). */
+ function valuesEqual(a, b) {
+ return a === b || String(a) === String(b);
+ }
+
+ /**
+ * Evaluates a view's rowClassRules against one row, returning the list of CSS classes to add. op grammar (§6.3):
+ * 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 = [];
+ (rules || []).forEach(function (r) {
+ var v = rowData ? rowData[r.field] : undefined;
+ var present = (v != null && v !== "");
+ var match = false;
+ switch (r.op) {
+ case "eq": match = valuesEqual(v, r.value); break;
+ case "ne": match = !valuesEqual(v, r.value); break;
+ case "present": match = present; break;
+ case "absent": match = !present; break;
+ default: match = false;
+ }
+ if (match) out.push(r["class"]);
+ });
+ return out;
+ }
+
+ /** 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++) {
+ if (i > 0 && (s.length - i) % 3 === 0) out += ",";
+ out += s.charAt(i);
+ }
+ return (n < 0 ? "-" : "") + out;
+ }
+
+ /**
+ * Builds the compact "n-n of n" paging-summary string (visual-parity design doc §4.C, item 3) from a
+ * DataTables `page.info()`-shaped object - pure, DOM/library-free (`pageInfo` is plain data). Mirrors
+ * DataTables' own default `language.info` convention (1-based inclusive display range: `start+1` to `end`)
+ * against `recordsDisplay` (the FILTERED count - the same number that default info text's `_TOTAL_`
+ * placeholder shows), falling back to `recordsTotal` if `recordsDisplay` is absent. An empty result set
+ * (`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;
+ if (!total) return "0-0 of 0";
+ return (pageInfo.start + 1) + "-" + pageInfo.end + " of " + formatThousands(total);
+ }
+
+ /**
+ * Computes the unified paging pill's selected/disabled state from a DataTables `page.info()`-shaped object
+ * (visual-parity design doc §4.C) - pure, DOM/library-free (`pageInfo` is plain data, never the DataTables API
+ * object itself). `first`/`previous` disable on the first page; `next`/`last` disable on the last page,
+ * INCLUDING the always-disabled-when-there-is-only-one-page case (`pages === 0`, e.g. an empty result set, is
+ * treated the same as "already on the last page").
+ */
+ function pillState(pageInfo, pageLength) {
+ var page = pageInfo.page, pages = pageInfo.pages;
+ return {
+ selectedLength: pageLength,
+ firstDisabled: page === 0,
+ prevDisabled: page === 0,
+ nextDisabled: pages === 0 || page === pages - 1,
+ lastDisabled: pages === 0 || page === pages - 1
+ };
+ }
+
+ /**
+ * Builds one DataTables column definition from a VIEW_META Column, binding its render id to the registry. In
+ * SERVER mode only the display facet runs (type !== "display" returns the raw value - the server did sort/filter).
+ * An unknown render id warns once and falls back to the raw value (never throws).
+ */
+ function buildColumnDef(col, deps) {
+ var def = {
+ data: col.data,
+ orderable: col.orderable !== false,
+ searchable: col.searchable !== false,
+ // The server's JSON serializer omits null-valued properties entirely, so a nullable column is simply
+ // ABSENT (undefined) on some rows. Per datatables.net/tn/4, DataTables' data accessor throws the
+ // "Requested unknown parameter" warning for undefined/null cell data unless columns.defaultContent is
+ // set - it's only consulted at that data-resolution step, so it never runs (and never suppresses a
+ // renderer) when the column's data IS present; present-but-null values still flow into `render` below,
+ // which already coerces null to "".
+ defaultContent: ""
+ };
+ if (col.title != null) def.title = col.title;
+ if (col.name != null) def.name = col.name;
+ if (col.className != null) def.className = col.className;
+
+ if (col.render) {
+ var spec = deps.parseRenderId(col.render);
+ var 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);
+ 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); }
+ catch (e) { return data == null ? "" : data; }
+ };
+ }
+ }
+ return def;
+ }
+
+ /** 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];
+ if (col.href != null) meta.href = col.href;
+ meta.column = col.data;
+ return meta;
+ }
+
+ /**
+ * Builds the DataTables options object from a VIEW_META view. `deps` supplies the pure renderer-registry hooks
+ * (parseRenderId/resolveRenderer/warn) and the ribbon param contributor (ribbonParams(activeState)). The
+ * serverSide fork (§6.7): server -> serverSide:true + ajax{dataSrc:"data"} (with ribbon active-toggle params
+ * merged into the request); client -> serverSide:false + ajax{dataSrc:""}.
+ */
+ function buildOptions(viewDef, deps) {
+ var 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
+ // as the input's placeholder attribute); the native length-select's language.lengthMenu is deliberately NOT
+ // set here - Task 9's paging pill hides and replaces that control entirely, so there is nothing left for it
+ // to style (design doc §4.B's resolved ambiguity).
+ opts.language = { search: "", searchPlaceholder: "Search" };
+ opts.pageLength = PAGE_SIZE_OPTIONS[0].value;
+ // Disable DataTables' own "Showing X to Y of Z entries" line at the source (rather than CSS-hiding it
+ // after the fact) - the unified paging pill (buildPagingPill/buildToolbarRow below) fully replaces it.
+ opts.info = false;
+
+ if (viewDef.dataMode === "server") {
+ opts.serverSide = true;
+ opts.ajax = {
+ 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];
+ return d;
+ }
+ };
+ } else {
+ opts.serverSide = false;
+ opts.ajax = { url: viewDef.dataUrl, dataSrc: "" };
+ }
+
+ opts.createdRow = function (rowEl, rowData) {
+ evaluateRowClassRules(viewDef.rowClassRules, rowData).forEach(function (cls) {
+ if (cls) rowEl.className += (rowEl.className ? " " : "") + cls;
+ });
+ };
+ return opts;
+ }
+
+ // ==================================================================================================================
+ // DOM / JQUERY BINDING LAYER (thin shim)
+ // ==================================================================================================================
+
+ function warn(msg) { if (window.console && console.warn) console.warn(msg); }
+ function error(msg) { if (window.console && console.error) console.error(msg); }
+
+ /**
+ * Builds one icon-only 32px toolbar button under the given `className` (visual-parity design doc §4.A/§4.C).
+ * Deliberately self-contained rather than reusing juneau-ribbon.js's button(...) helper (Design decision #3):
+ * this module's buttons are not RibbonActions and have no built-in-id/symbol-override duality to resolve, so
+ * they resolve their fixed glyph names directly via window.JuneauViews.icons.resolveIcon(...) - the two files
+ * share the icon mechanism without either depending on the other's toolbar-construction logic. Falls back to
+ * 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");
+ 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;
+ if (markup != null) {
+ b.innerHTML = markup;
+ } else {
+ b.textContent = b.title;
+ }
+ b.addEventListener("click", onClick);
+ return b;
+ }
+
+ /** One paging-ribbon nav segment - see toolbarButton(...). */
+ function pagingPillButton(label, iconName, onClick) {
+ return toolbarButton("juneau-view-pagingpill-btn", label, iconName, onClick);
+ }
+
+ /**
+ * Builds the central "n-n of n" segment of the unified paging ribbon (visual-parity follow-up: consolidates
+ * the old standalone page-size <select> INTO this segment rather than keeping it as a separate control). It
+ * is a WAI-ARIA "menu button": the button's own visible content IS the compact range summary (so it never
+ * shows a redundant "Page size" label), `aria-haspopup="listbox"` + `aria-expanded` mark it as a popup
+ * trigger, and the popup is a `role="listbox"` list with one `role="option"` per `PAGE_SIZE_OPTIONS` entry -
+ * the SAME option set/labels the old <select> offered. Selecting an option calls the same DataTables
+ * `page.len(n).draw()` API the old <select>'s `change` handler used.
+ *
+ * <p>Keyboard: Enter/Space/ArrowDown on the button opens the popup (focusing its currently-selected option, or
+ * the first one); ArrowUp/ArrowDown move focus between options while open; Enter/Space on a focused option
+ * selects it; Escape closes the popup and returns focus to the button (also true of a plain option click, and
+ * of focus leaving the control entirely).
+ */
+ function buildPageSizeMenu(ctx) {
+ var wrap = document.createElement("span");
+ wrap.className = "juneau-view-pagingpill-menuwrap";
+
+ var 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");
+ 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");
+ 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");
+ 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");
+ optEl.className = "juneau-view-pagingpill-menu-option";
+ optEl.setAttribute("role", "option");
+ optEl.tabIndex = -1;
+ optEl.textContent = o.label;
+ optEl.addEventListener("click", function () { selectOption(o.value); });
+ menuEl.appendChild(optEl);
+ return { value: o.value, el: optEl };
+ });
+
+ function indexOfSelected() {
+ for (var 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;
+ return -1;
+ }
+
+ function openMenu() {
+ menuEl.hidden = false;
+ btn.setAttribute("aria-expanded", "true");
+ var idx = indexOfSelected();
+ options[idx >= 0 ? idx : 0].el.focus();
+ }
+
+ function closeMenu(returnFocusToButton) {
+ menuEl.hidden = true;
+ btn.setAttribute("aria-expanded", "false");
+ if (returnFocusToButton) btn.focus();
+ }
+
+ function selectOption(value) {
+ ctx.dataTable.page.len(value).draw();
+ closeMenu(true);
+ }
+
+ btn.addEventListener("click", function () {
+ if (menuEl.hidden) openMenu(); else closeMenu(false);
+ });
+ btn.addEventListener("keydown", function (e) {
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ openMenu();
+ }
+ });
+ menuEl.addEventListener("keydown", function (e) {
+ var idx = indexOfFocused();
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ options[(idx + 1) % options.length].el.focus();
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ options[(idx - 1 + options.length) % options.length].el.focus();
+ } else if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ if (idx >= 0) selectOption(options[idx].value);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ closeMenu(true);
+ }
+ });
+ // Closing on focus-out (rather than only on Escape/selection) covers a mouse click or Tab landing
+ // anywhere outside the control - a real menu button must never leave its popup open once focus moves on.
+ wrap.addEventListener("focusout", function (e) {
+ if (!wrap.contains(e.relatedTarget)) closeMenu(false);
+ });
+
+ wrap.appendChild(btn);
+ wrap.appendChild(menuEl);
+
+ return {
+ el: wrap,
+ refresh: function (summaryText, selectedLength) {
+ infoEl.textContent = summaryText;
+ options.forEach(function (o) {
+ o.el.setAttribute("aria-selected", String(o.value) === String(selectedLength) ? "true" : "false");
+ });
+ }
+ };
+ }
+
+ /**
+ * Builds the unified paging ribbon - `[First] [Prev] [<range> + page-size menu] [Next] [Last]` - and wires it
+ * to the given DataTables instance (visual-parity follow-up). Called unconditionally from initTable(...) -
+ * every view table gets one, regardless of whether it declares any ribbon actions. Supersedes BOTH the old
+ * standalone page-size <select> (folded into buildPageSizeMenu's central segment above) AND the old
+ * right-side compact prev/next ribbon (removed entirely) - paging now exists in exactly ONE place. Returns
+ * the bare pill element only - it no longer touches the DOM itself (buildToolbarRow(...) below owns all
+ * toolbar-row DOM insertion in one place). There is no native ".dataTables_info"/".dt-info" node to move -
+ * buildOptions(...) sets `info: false`, so DataTables never creates that node in the first place.
+ */
+ function buildPagingPill(viewDef, ctx) {
+ var 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(); });
+ pill.appendChild(firstBtn);
+ pill.appendChild(prevBtn);
+ pill.appendChild(sizeMenu.el);
+ pill.appendChild(nextBtn);
+ pill.appendChild(lastBtn);
+
+ function refreshPillState() {
+ var info = ctx.dataTable.page.info();
+ var st = pillState(info, ctx.dataTable.page.len());
+ firstBtn.disabled = st.firstDisabled;
+ prevBtn.disabled = st.prevDisabled;
+ nextBtn.disabled = st.nextDisabled;
+ lastBtn.disabled = st.lastDisabled;
+ sizeMenu.refresh(pagingSummaryText(info), st.selectedLength);
+ }
+ ctx.dataTable.on("draw.dt", refreshPillState);
+ refreshPillState(); // correct initial disabled state before the first draw.dt fires
+
+ return pill;
+ }
+
+ /**
+ * Builds and inserts the per-column search `<tr>` into `table`'s `<thead>` (visual-parity §4, item 4 - fixes
+ * the columnSearchToggle button, which toggled `ctx.columnSearchOn` but had nothing wired to
+ * `ctx.onColumnSearchToggle` to actually show/hide or filter anything). One text input per SEARCHABLE column
+ * (a non-searchable column gets an empty cell, keeping column count/alignment intact); each input's `input`
+ * event applies a simple per-column text filter via `dt.column(idx).search(value).draw()`. Starts hidden
+ * (`ctx.onColumnSearchToggle`, wired by the caller, toggles visibility). Returns null when the table has no
+ * `<thead>` (defensive; every juneau view table has one).
+ */
+ function buildColumnSearchRow(table, viewDef, dt) {
+ var thead = table.querySelector("thead");
+ if (!thead) return null;
+ var 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");
+ if (col.searchable !== false) {
+ var input = document.createElement("input");
+ input.type = "text";
+ input.className = "juneau-view-columnsearch-input";
+ var 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(); });
+ th.appendChild(input);
+ }
+ row.appendChild(th);
+ });
+ thead.appendChild(row);
+ return row;
+ }
+
+ /** 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;
+ if (!caption) {
+ caption = document.createElement("caption");
+ table.insertBefore(caption, table.firstChild);
+ }
+ caption.className = "juneau-view-error";
+ caption.textContent = message;
+ }
+
+ /**
+ * Assembles ONE unified toolbar row and inserts it as the FIRST child of `wrapper`, i.e. ABOVE the table (IRS
+ * reference layout). Per the control-row layout spec: a LEFT cluster (`.juneau-view-toolbar-left`) holding
+ * just the unified paging ribbon (nav + page-size, left-aligned - the only place paging exists), and a RIGHT
+ * cluster (`.juneau-view-toolbar-right`, right-aligned via the row's `space-between`) holding, in order: the
+ * native DataTables search box, then the ribbon bar (already internally grouped into filter-ribbon/copy-
+ * download-ribbon clusters per juneau-ribbon.js). `pill`/`bar` are each optional (a view with no ribbon
+ * actions still gets its pill-only row; a pill is always built by initTable(...) so it is realistically
+ * always present). Moves DataTables' native ".dataTables_filter"/".dt-search" search box into the right
+ * cluster rather than leaving it in its own DataTables-generated wrapper; degrades gracefully (right cluster
+ * still built from whichever of search/bar exist) when no native search box is found (e.g. searching
+ * disabled).
+ */
+ function buildToolbarRow(wrapper, pill, bar) {
+ var filterEl = wrapper.querySelector(".dataTables_filter, .dt-search");
+ var row = document.createElement("div");
+ row.className = "juneau-view-toolbar-row";
+
+ var left = document.createElement("div");
+ left.className = "juneau-view-toolbar-left";
+ if (pill) left.appendChild(pill);
+
+ var right = document.createElement("div");
+ right.className = "juneau-view-toolbar-right";
+ if (filterEl) right.appendChild(filterEl);
+ if (bar) right.appendChild(bar);
+
+ row.appendChild(left);
+ row.appendChild(right);
+ wrapper.insertBefore(row, wrapper.firstChild);
+ return row;
+ }
+
+ function initTable(table) {
+ var $ = window.jQuery;
+ var id = table.getAttribute("data-juneau-view");
+ var sidecar = document.getElementById("juneau-view:" + id);
+ if (!sidecar) { error("Juneau view '" + id + "': missing JSON sidecar; refusing to init."); return; }
+
+ var viewDef;
+ try {
+ viewDef = JSON.parse(sidecar.textContent);
+ } catch (e) {
+ error("Juneau view '" + id + "': malformed JSON sidecar; refusing to init.");
+ renderBanner(table, "Juneau view '" + id + "': malformed configuration.");
+ return;
+ }
+
+ // FAIL-LOUD contract-version handshake (§6.2): a mismatch means the served JS is stale vs the JSON - refuse.
+ if (viewDef.contractVersion !== JUNEAU_VIEW_CONTRACT_VERSION) {
+ var 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) {
+ 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) : {};
+
+ var deps = {
+ parseRenderId: NS.parseRenderId,
+ resolveRenderer: NS.resolveRenderer,
+ warn: warn,
+ ribbonParams: function () {
+ return (NS.ribbon && NS.ribbon.ribbonToQueryParams) ? NS.ribbon.ribbonToQueryParams(viewDef, activeState) : {};
+ }
+ };
+
+ var opts = buildOptions(viewDef, deps);
+ var dt = $(table).DataTable(opts);
+
+ var 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 = {
+ table: table,
+ dataTable: dt,
+ activeState: activeState,
+ columnSearchOn: false,
+ redraw: function () { dt.ajax ? dt.ajax.reload() : dt.draw(); }
+ };
+
+ var bar = (NS.ribbon && NS.ribbon.build) ? NS.ribbon.build(viewDef, ctx) : null;
+
+ var columnSearchRow = buildColumnSearchRow(table, viewDef, dt);
+ ctx.onColumnSearchToggle = function (on) {
+ if (!columnSearchRow) return;
+ columnSearchRow.style.display = on ? "" : "none";
+ if (!on) {
+ Array.prototype.forEach.call(columnSearchRow.querySelectorAll("input"), function (inp) { inp.value = ""; });
+ dt.columns().search("").draw();
+ }
+ };
+
+ // 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);
+ }
+
+ /**
+ * 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
+ * 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]");
+ Array.prototype.forEach.call(tables, function (t) {
+ if (t.closest && t.closest("[data-juneau-page]")) return;
+ initTable(t);
+ });
+ }
+
+ // ==================================================================================================================
+ // PUBLIC API + bootstrap
+ // ==================================================================================================================
+
+ NS.init = {
+ PAGE_SIZE_OPTIONS: PAGE_SIZE_OPTIONS,
+ columnIndexOf: columnIndexOf,
+ resolveOrder: resolveOrder,
+ valuesEqual: valuesEqual,
+ evaluateRowClassRules: evaluateRowClassRules,
+ formatThousands: formatThousands,
+ pagingSummaryText: pagingSummaryText,
+ pillState: pillState,
+ buildColumnDef: buildColumnDef,
+ 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.
+ initTable: initTable,
+ // visual-parity pass: exposed for Option-A/manual verification.
+ buildPagingPill: buildPagingPill,
+ buildPageSizeMenu: buildPageSizeMenu,
+ buildColumnSearchRow: buildColumnSearchRow,
+ buildToolbarRow: buildToolbarRow
+ };
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initAll);
+ } else {
+ initAll();
+ }
+})();
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
new file mode 100644
index 0000000000..0a5da6d02c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
@@ -0,0 +1,166 @@
+/*
+ * 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.junit.jupiter.api.*;
+
+/**
+ * {@code juneau-pages.js} pure hash-routing logic + DOM binding shim tests (TODO-399 Phase C, Tasks 7-8).
+ *
+ * <p>
+ * <b>Test-approach resolution (⚠️ UNVERIFIED in the plan):</b> this module has no JS-execution (jsdom/Option-B)
+ * harness — confirmed while implementing Task 6 by inspecting {@code ViewsMixin_Serving_Test}, whose {@code
+ * e03}/{@code g04} tests are Option-A-only (served-script content-substring assertions, with an explicit code
+ * comment noting true JS-execution verification "awaits the deferred Option-B (jsdom) harness"). Per that
+ * established convention, these tests extract each pure function's source body from the served script and assert
+ * on its control-flow markers, mirroring {@code g04_viewsJs_columnDefsSetDefaultContentForUndefinedSafety}'s
+ * function-body-extraction style. The functions are still written as a DOM-free pure layer (see the class-header
+ * comment in {@code juneau-pages.js}), so a future Option-B harness can execute them directly with zero
+ * refactoring — only the verification depth of these particular tests would change.
+ */
+class JuneauPagesJs_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String pagesJs() throws Exception {
+ return c.get(ViewsMixin.PAGES_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ /** Extracts a top-level function's source body (from its `function name(` line to the next top-level `\n\t}`). */
+ private static String functionBody(String src, String functionName) {
+ var start = src.indexOf("function " + functionName + "(");
+ assertTrue(start >= 0, () -> functionName + " not found:\n" + src);
+ var end = src.indexOf("\n\t}", start);
+ return src.substring(start, end < 0 ? src.length() : end);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Task 7: pure hash-routing logic - parseHash / resolveInitial / hashFor
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_parseHashIsPureAndPubliclyExposed() throws Exception {
+ var body = pagesJs();
+ assertTrue(body.contains("NS.pages = {"), body);
+ assertTrue(body.contains("parseHash: parseHash"), body);
+ var fn = functionBody(body, "parseHash");
+ // DOM-free: splits the hash on "/" - no document/window DOM reads inside the function itself.
+ assertTrue(fn.contains("split(\"/\")"), fn);
+ assertFalse(fn.contains("document."), fn);
+ }
+
+ @Test void a02_resolveInitialExposedAndFallsBackToFirstTabAndSubtab() throws Exception {
+ var body = pagesJs();
+ assertTrue(body.contains("resolveInitial: resolveInitial"), body);
+ var fn = functionBody(body, "resolveInitial");
+ // Fallback-to-first-tab-when-missing/unknown (pt.1 of the design doc's page-runtime responsibilities).
+ assertTrue(fn.contains("tabs[0]"), fn);
+ // A tab with subtabs falls back to ITS first subtab too.
+ assertTrue(fn.contains("tab.subtabs[0]"), fn);
+ assertFalse(fn.contains("document."), fn);
+ }
+
+ @Test void a03_resolveInitialRejectsHashFromADifferentPage() throws Exception {
+ var body = pagesJs();
+ var fn = functionBody(body, "resolveInitial");
+ // A hash targeting a different pageId must not be treated as this page's active tab.
+ assertTrue(fn.contains("parsed.pageId === pageMeta.id"), fn);
+ }
+
+ @Test void a04_hashForExposedAndBuildsThreeSegmentHash() throws Exception {
+ var body = pagesJs();
+ assertTrue(body.contains("hashFor: hashFor"), body);
+ var fn = functionBody(body, "hashFor");
+ assertTrue(fn.contains("\"#\" + pageId"), fn);
+ assertTrue(fn.contains("subtabId"), fn);
+ }
+
+ @Test void a05_findByIdIsPureIdEqualityLookup() throws Exception {
+ var body = pagesJs();
+ var fn = functionBody(body, "findById");
+ assertTrue(fn.contains("list[i].id === id"), fn);
+ assertFalse(fn.contains("document."), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Task 8: DOM binding shim - lazy init, columns.adjust() on re-show, hashchange wiring, no-selector-injection
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_activatePanelViews_lazyInitsFirstThenAdjustsColumnsOnReShow() throws Exception {
+ var body = pagesJs();
+ var fn = functionBody(body, "activatePanelViews");
+ // First activation: idempotent isDataTable guard decides between NS.init.initTable(...) (first time)...
+ assertTrue(fn.contains("isDataTable(t)"), fn);
+ assertTrue(fn.contains("NS.init.initTable"), fn);
+ // ...and columns.adjust() (every subsequent show - fixes sizing deferred while display:none).
+ assertTrue(fn.contains("columns.adjust()"), fn);
+ }
+
+ @Test void b02_hashchangeListenerWired() throws Exception {
+ var body = pagesJs();
+ assertTrue(body.contains("addEventListener(\"hashchange\""), body);
+ }
+
+ @Test void b03_activeStateReflectedViaAttributeComparisonNotSelectorInjection() throws Exception {
+ // Security: hash-derived tabId/subtabId must be compared against existing elements' attribute VALUES
+ // (getAttribute(...) === ...), never interpolated into a querySelector(...) string or innerHTML.
+ var body = pagesJs();
+ var fn = functionBody(body, "showActive");
+ assertTrue(fn.contains("getAttribute(\"data-tab-id\") === tabId"), fn);
+ assertTrue(fn.contains("getAttribute(\"data-subtab-id\") === subtabId"), fn);
+ assertFalse(fn.contains("querySelector(\"[data-tab-id=\""), fn);
+ assertFalse(fn.contains("innerHTML"), fn);
+ }
+
+ @Test void b04_panelVisibilityToggledViaClassNotInlineStyle() throws Exception {
+ // No caller/hash-derived string is ever written into style="..." - only a static class name is toggled.
+ var body = pagesJs();
+ var fn = functionBody(body, "showActive");
+ assertTrue(fn.contains("classList.toggle(\"jc-active\""), fn);
+ assertFalse(fn.contains(".style."), fn);
+ }
+
+ @Test void b05_pageContractVersionMismatchRefusesToInitAndRendersBanner() throws Exception {
+ var body = pagesJs();
+ assertTrue(body.contains("contractVersion !== JUNEAU_PAGE_CONTRACT_VERSION"), body);
+ assertTrue(body.contains("renderBanner("), body);
+ assertTrue(body.contains("jc-page-error"), body);
+ }
+
+ @Test void b06_missingOrMalformedSidecarRefusesToInitRatherThanThrow() throws Exception {
+ var body = pagesJs();
+ var fn = functionBody(body, "initPage");
+ assertTrue(fn.contains("if (!sidecar)"), fn);
+ assertTrue(fn.contains("JSON.parse(sidecar.textContent)"), fn);
+ assertTrue(fn.contains("catch (e)"), fn);
+ }
+
+ @Test void b07_initAllPagesScansDataJuneauPageShellsOnDomContentLoaded() throws Exception {
+ var body = pagesJs();
+ assertTrue(body.contains("querySelectorAll(\"[data-juneau-page]\")"), body);
+ assertTrue(body.contains("DOMContentLoaded"), body);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
new file mode 100644
index 0000000000..a0d9e1d34d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Builder + JSON-serialization contract test for {@link PageDef}/{@link Tab}/{@link Subtab} (Phase C, Task 1).
+ *
+ * <p>
+ * Mirrors {@code ViewMeta_Contract_Test}'s structural-compare style: pins the builder round-trip, the ordered
+ * tab/subtab lists, and the serialized shape (baked {@code contractVersion}, preserved ids) without over-specifying
+ * whitespace/key-order beyond the top-level discriminator fields.
+ */
+class PageDef_Contract_Test extends TestBase {
+
+ public static class Release {
+ public String name;
+ public String status;
+ }
+
+ private static ViewDef view(String id) {
+ return ViewDef.create(id)
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/" + id + "/data")
+ .columns(Column.of("name").title("Name"))
+ .build();
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Builder round-trip
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_builderRoundTrip_leafTabsOnly() {
+ var page = PageDef.create("admin")
+ .title("Admin")
+ .tabs(
+ Tab.create("releases", "Releases").view(view("releases")),
+ Tab.create("users", "Users").view(view("users")))
+ .build();
+
+ assertEquals("admin", page.id);
+ assertEquals("Admin", page.title);
+ assertEquals(2, page.tabs.size());
+ assertEquals("releases", page.tabs.get(0).id);
+ assertEquals("Releases", page.tabs.get(0).label);
+ assertEquals("releases", page.tabs.get(0).view.id);
+ assertEquals("users", page.tabs.get(1).id);
+ }
+
+ @Test void a02_builderRoundTrip_withSubtabs() {
+ var page = PageDef.create("admin")
+ .tabs(
+ Tab.create("releases", "Releases").view(view("releases")),
+ Tab.create("catalog", "Catalog").subtabs(
+ Subtab.create("packages", "Packages").view(view("packages")),
+ Subtab.create("bundles", "Bundles").view(view("bundles"))))
+ .build();
+
+ var catalog = page.tabs.get(1);
+ assertNull(catalog.view);
+ assertEquals(2, catalog.subtabs.size());
+ assertEquals("packages", catalog.subtabs.get(0).id);
+ assertEquals("Packages", catalog.subtabs.get(0).label);
+ assertEquals("packages", catalog.subtabs.get(0).view.id);
+ assertEquals("bundles", catalog.subtabs.get(1).id);
+ }
+
+ @Test void a03_contractVersionDefaultsToViewDefContractVersion() {
+ var page = PageDef.create("admin").tabs(Tab.create("t", "T").view(view("v"))).build();
+ assertEquals(ViewDef.CONTRACT_VERSION, page.contractVersion);
+ }
+
+ @Test void a04_tabOrderPreserved() {
+ var page = PageDef.create("admin")
+ .tabs(
+ Tab.create("c", "C").view(view("c")),
+ Tab.create("a", "A").view(view("a")),
+ Tab.create("b", "B").view(view("b")))
+ .build();
+ assertEquals(List.of("c", "a", "b"), page.tabs.stream().map(t -> t.id).toList());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // JSON serialization shape
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_serializesWithContractVersionAndIds() {
+ var page = PageDef.create("admin")
+ .title("Admin")
+ .tabs(Tab.create("releases", "Releases").view(view("releases")))
+ .build();
+ var json = Json.of(page);
+ assertTrue(json.contains("\"contractVersion\":\"2\""), json);
+ assertTrue(json.contains("\"id\":\"admin\""), json);
+ assertTrue(json.contains("\"title\":\"Admin\""), json);
+ assertTrue(json.contains("\"tabs\":["), json);
+ }
+
+ @Test void b02_leafTabSerializesViewNotSubtabs() {
+ var page = PageDef.create("admin").tabs(Tab.create("releases", "Releases").view(view("releases"))).build();
+ var json = Json.of(page);
+ assertTrue(json.contains("\"view\":"), json);
+ assertFalse(json.contains("\"subtabs\""), json);
+ }
+
+ @Test void b03_subtabbedTabSerializesSubtabsNotView() {
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("packages", "Packages").view(view("packages"))))
+ .build();
+ var json = Json.of(page);
+ assertTrue(json.contains("\"subtabs\":["), json);
+ // The Tab's own "view" key must be omitted (not null) when subtabs are used instead.
+ assertFalse(json.contains("\"view\":null"), json);
+ }
+
+ @Test void b04_titleOmittedWhenUnset() {
+ // NOTE: checked structurally (not via a raw substring search) because the referenced view's own Column
+ // carries an unrelated "title" key - a substring check would false-positive on that nested field.
+ var page = PageDef.create("admin").tabs(Tab.create("t", "T").view(view("v"))).build();
+ var top = Json.to(Json.of(page), java.util.Map.class);
+ assertFalse(top.containsKey("title"), () -> Json.of(page));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Fluent-setter null-safety on Tab/Subtab labels
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_tabCreateNullIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> Tab.create(null, "Label"));
+ assertTrue(e.getMessage().contains("null"), e::getMessage);
+ }
+
+ @Test void c02_subtabCreateBlankIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> Subtab.create(" ", "Label"));
+ assertTrue(e.getMessage().contains("blank"), e::getMessage);
+ }
+
+ @Test void c03_pageDefCreateNullIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create(null));
+ assertTrue(e.getMessage().contains("null"), e::getMessage);
+ }
+}
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
new file mode 100644
index 0000000000..a8ea648b87
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Validation_Test.java
@@ -0,0 +1,160 @@
+/*
+ * 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.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validation contract test for {@link PageDef}/{@link Tab}/{@link Subtab} (Phase C, Task 2, design doc §"Bean
+ * model").
+ *
+ * <p>
+ * Pins the four rejection rules: a {@link Tab} declaring both/neither {@code view}/{@code subtabs}; duplicate
+ * {@code Tab.id} within a page; duplicate {@code Subtab.id} within a tab; and a referenced {@link ViewDef#id}
+ * repeated anywhere across the page (so hash routing + sidecar lookup stay unambiguous).
+ *
+ * <p>
+ * <b>Resolution of the plan's "unique Tab.id / Subtab.id within a page" wording:</b> {@code Tab.id} is required
+ * unique across the whole page (it is the second hash segment, {@code #pageId/tabId/...}); {@code Subtab.id} is
+ * required unique only within its own parent {@code Tab} (it is only ever resolved <i>after</i> the tab id has
+ * already disambiguated the parent, per the {@code #pageId/tabId/subtabId} hash shape) — so the same subtab
+ * id may safely recur under two different tabs.
+ */
+class PageDef_Validation_Test extends TestBase {
+
+ public static class Release {
+ public String name;
+ }
+
+ private static ViewDef view(String id) {
+ return ViewDef.create(id)
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/" + id + "/data")
+ .columns(Column.of("name").title("Name"))
+ .build();
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Tab must have exactly one of view/subtabs
+ //------------------------------------------------------------------------------------------------------------------
+
+ @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());
+ 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());
+ 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());
+ assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Duplicate tab ids (page-wide)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @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());
+ assertTrue(e.getMessage().contains("releases"), e::getMessage);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Duplicate subtab ids (within one tab)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @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());
+ assertTrue(e.getMessage().contains("packages"), e::getMessage);
+ }
+
+ @Test void c02_sameSubtabIdAcrossDifferentTabs_allowed() {
+ // Different parent tabs -> no collision (the hash's tabId segment already disambiguates).
+ var page = PageDef.create("admin")
+ .tabs(
+ Tab.create("a", "A").subtabs(Subtab.create("x", "X").view(view("ax"))),
+ Tab.create("b", "B").subtabs(Subtab.create("x", "X").view(view("bx"))))
+ .build();
+ assertEquals("x", page.tabs.get(0).subtabs.get(0).id);
+ assertEquals("x", page.tabs.get(1).subtabs.get(0).id);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Duplicate referenced ViewDef.id across the whole page
+ //------------------------------------------------------------------------------------------------------------------
+
+ @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());
+ 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());
+ assertTrue(e.getMessage().contains("shared"), e::getMessage);
+ }
+
+ @Test void d03_uniqueViewIds_accepted() {
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("a", "A").view(view("va")), Tab.create("b", "B").view(view("vb")))
+ .build();
+ assertEquals(2, page.tabs.size());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // PageDef requires at least one tab
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_noTabs_rejected() {
+ var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create("admin").build());
+ assertTrue(e.getMessage().contains("tab"), e::getMessage);
+ }
+
+ @Test void e02_emptyTabsList_rejected() {
+ var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create("admin").tabs().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
new file mode 100644
index 0000000000..40fbf53078
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageServerWiring_Test.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.rest.server.views;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+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.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+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).
+ *
+ * <p>
+ * Proves a REST resource composing {@link ViewsMixin} and returning a {@code PageTable.of(...)} response emits a
+ * well-formed page: the {@code [data-juneau-page]} shell, every child view's marker table + VIEW_META sidecar, the
+ * PAGE_META sidecar, and (via {@code ViewsMixin}) the asset links a real page would load — including the new
+ * opt-in {@code juneau-pages.js} alongside the existing {@code juneau-views.js}/{@code juneau-ribbon.js}/
+ * {@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).
+ */
+class PageServerWiring_Test extends TestBase {
+
+ public static class Release {
+ public String name;
+ public String status;
+ }
+
+ private static ViewDef view(String id) {
+ return ViewDef.create(id)
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/" + id + "/data")
+ .columns(Column.of("name").title("Name"), Column.of("status").title("Status"))
+ .build();
+ }
+
+ private static PageDef buildAdminPage() {
+ return PageDef.create("admin")
+ .title("Admin")
+ .tabs(
+ Tab.create("releases", "Releases").view(view("releases")),
+ Tab.create("catalog", "Catalog").subtabs(
+ Subtab.create("packages", "Packages").view(view("packages")),
+ Subtab.create("bundles", "Bundles").view(view("bundles"))))
+ .build();
+ }
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class PageHost extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/admin") public HttpResource adminPage() {
+ var markup = Html.of(PageTable.of(buildAdminPage()));
+ return HttpResourceBean.of(
+ ByteArrayBody.of(markup.getBytes(StandardCharsets.UTF_8), "text/html;charset=utf-8"),
+ list(ContentType.of("text/html;charset=utf-8")));
+ }
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(PageHost.class);
+
+ private static String sidecarBody(String html, String sidecarId) {
+ var open = html.indexOf("id=\"" + sidecarId + "\"");
+ assertTrue(open >= 0, () -> "sidecar '" + sidecarId + "' not found:\n" + html);
+ var contentStart = html.indexOf('>', open) + 1;
+ var contentEnd = html.indexOf("</script>", contentStart);
+ return html.substring(contentStart, contentEnd);
+ }
+
+ @Test void a01_pageEndpointEmitsShellAndAllChildViews() throws Exception {
+ var html = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+ assertTrue(html.contains("data-juneau-page=\"admin\""), html);
+ for (var id : List.of("releases", "packages", "bundles")) {
+ assertTrue(html.contains("data-juneau-view=\"" + id + "\""), html);
+ assertTrue(html.contains("id=\"juneau-view:" + id + "\""), html);
+ }
+ assertTrue(html.contains("id=\"juneau-page:admin\""), html);
+ }
+
+ @Test void a02_pageMetaRoundTripsTabTree() throws Exception {
+ var html = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+ var body = sidecarBody(html, "juneau-page:admin");
+ var meta = Json.to(body, Map.class);
+ assertEquals("admin", meta.get("id"));
+ assertEquals(PageDef.CONTRACT_VERSION, meta.get("contractVersion"));
+ var tabs = (List<?>) meta.get("tabs");
+ assertEquals(2, tabs.size());
+ }
+
+ @Test void a03_childViewSidecarsRoundTripToTheirOwnViewDef() throws Exception {
+ var html = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+ var body = sidecarBody(html, "juneau-view:releases");
+ var expected = Json.to(Json.of(view("releases")), Map.class);
+ var actual = Json.to(body, Map.class);
+ assertEquals(expected, actual, body);
+ }
+
+ @Test void a04_repeatedRequests_areByteIdentical() throws Exception {
+ var html1 = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+ var html2 = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+ assertEquals(html1, html2);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // The host also exposes the full asset set a real page would load (ViewsMixin, including the new pages.js).
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_hostServesAllFiveAssetsIncludingPagesJs() throws Exception {
+ for (var path : List.of(
+ ViewsMixin.VIEWS_JS_PATH, ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH,
+ ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.PAGES_JS_PATH))
+ c.get(path).run().assertStatus(200);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
new file mode 100644
index 0000000000..8a93248bf4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+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).
+ *
+ * <p>
+ * Proves the emitter produces the {@code data-juneau-page} shell, a tab-bar entry per {@link Tab} (+ a sub-tab bar
+ * per {@link Tab} with subtabs), a panel per referenced view carrying the <b>same</b> {@code data-juneau-view}
+ * marker + VIEW_META sidecar {@code ViewTable.of(...)} already emits, and one PAGE_META sidecar — and that all
+ * server-emitted labels/titles are HTML-escaped with no {@code url()}/inline-style injection.
+ */
+class PageTable_Emit_Test extends TestBase {
+
+ public static class Release {
+ public String name;
+ }
+
+ private static ViewDef view(String id) {
+ return ViewDef.create(id)
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/" + id + "/data")
+ .columns(Column.of("name").title("Name"))
+ .build();
+ }
+
+ private static PageDef leafPage() {
+ return PageDef.create("admin")
+ .title("Admin")
+ .tabs(
+ Tab.create("releases", "Releases").view(view("releases")),
+ Tab.create("users", "Users").view(view("users")))
+ .build();
+ }
+
+ private static PageDef pageWithSubtabs() {
+ return PageDef.create("admin")
+ .tabs(
+ Tab.create("releases", "Releases").view(view("releases")),
+ Tab.create("catalog", "Catalog").subtabs(
+ Subtab.create("packages", "Packages").view(view("packages")),
+ Subtab.create("bundles", "Bundles").view(view("bundles"))))
+ .build();
+ }
+
+ /** Extracts the raw text between a sidecar's opening and closing {@code <script>} tags by element id. */
+ private static String sidecarBody(String html, String sidecarId) {
+ var open = html.indexOf("id=\"" + sidecarId + "\"");
+ assertTrue(open >= 0, () -> "sidecar '" + sidecarId + "' not found:\n" + html);
+ var contentStart = html.indexOf('>', open) + 1;
+ var contentEnd = html.indexOf("</script>", contentStart);
+ return html.substring(contentStart, contentEnd);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Structure (Task 3)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_emitsPageShellMarker() {
+ var html = Html.of(PageTable.of(leafPage()));
+ assertTrue(html.contains("data-juneau-page=\"admin\""), html);
+ assertTrue(html.contains(PageTable.PAGE_CLASS), html);
+ }
+
+ @Test void a02_emitsOneTabBarEntryPerTab() {
+ var html = Html.of(PageTable.of(leafPage()));
+ assertTrue(html.contains("data-tab-id=\"releases\""), html);
+ assertTrue(html.contains("data-tab-id=\"users\""), html);
+ assertTrue(html.contains(PageTable.TAB_CLASS), html);
+ }
+
+ @Test void a03_emitsSubTabBarOnlyForTabsWithSubtabs() {
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ assertTrue(html.contains("data-subtab-id=\"packages\""), html);
+ assertTrue(html.contains("data-subtab-id=\"bundles\""), html);
+ assertTrue(html.contains(PageTable.SUBTAB_CLASS), html);
+ // The leaf "releases" tab has no subtabs -> no subtab-bar markers referencing it.
+ assertFalse(html.contains("data-parent-tab=\"releases\""), html);
+ }
+
+ @Test void a04_eachViewGetsItsOwnMarkerTableAndSidecar() {
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ for (var id : List.of("releases", "packages", "bundles")) {
+ assertTrue(html.contains("data-juneau-view=\"" + id + "\""), html);
+ assertTrue(html.contains("id=\"juneau-view:" + id + "\""), html);
+ }
+ }
+
+ @Test void a05_childViewOutputIsByteForByteIdenticalToStandaloneViewTable() {
+ var v = view("releases");
+ var standalone = Html.of(ViewTable.of(v));
+ var page = PageDef.create("admin").tabs(Tab.create("releases", "Releases").view(v)).build();
+ var pageHtml = Html.of(PageTable.of(page));
+ assertTrue(pageHtml.contains(standalone), () -> "page output does not contain the standalone ViewTable output verbatim:\n" + pageHtml + "\n---standalone---\n" + standalone);
+ }
+
+ @Test void a06_emitsOnePageMetaSidecar() {
+ var html = Html.of(PageTable.of(leafPage()));
+ assertTrue(html.contains("id=\"juneau-page:admin\""), html);
+ }
+
+ @Test void a07_pageMetaCarriesTabTreeIds() {
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ var body = sidecarBody(html, "juneau-page:admin");
+ var meta = Json.to(body, Map.class);
+ assertEquals("admin", meta.get("id"));
+ var tabs = (List<?>) meta.get("tabs");
+ assertEquals(2, tabs.size());
+ var releasesTab = (Map<?,?>) tabs.get(0);
+ assertEquals("releases", releasesTab.get("id"));
+ assertEquals("Releases", releasesTab.get("label"));
+ assertFalse(releasesTab.containsKey("subtabs"), releasesTab::toString);
+ var catalogTab = (Map<?,?>) tabs.get(1);
+ var subtabs = (List<?>) catalogTab.get("subtabs");
+ assertEquals(2, subtabs.size());
+ assertEquals("packages", ((Map<?,?>) subtabs.get(0)).get("id"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Escaping / chrome (Task 4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_tabLabelsAreHtmlEscaped() {
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("t", "<script>alert(1)</script>").view(view("v")))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ assertFalse(html.contains("<script>alert(1)</script>"), html);
+ assertTrue(html.contains("<script>"), html);
+ }
+
+ @Test void b02_subtabLabelsAreHtmlEscaped() {
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("t", "T").subtabs(Subtab.create("s", "<img src=x onerror=alert(1)>").view(view("v"))))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ assertFalse(html.contains("<img src=x onerror=alert(1)>"), html);
+ }
+
+ @Test void b03_pageTitleIsHtmlEscapedIfRendered() {
+ var page = PageDef.create("admin").title("<b>Admin</b>").tabs(Tab.create("t", "T").view(view("v"))).build();
+ var html = Html.of(PageTable.of(page));
+ assertFalse(html.contains("<b>Admin</b>"), html);
+ }
+
+ @Test void b04_noInlineStyleAttributeEmitted() {
+ // The emitter must never write a caller string into style="..." (a label containing "url(...)" is just
+ // escaped display text here, not a CSS injection vector, since no style="" attribute exists at all).
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("t", "url(javascript:alert(1))").view(view("v")))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ assertFalse(html.contains("style=\""), html);
+ }
+
+ @Test void b05_pageMetaCarriesContractVersion() {
+ var html = Html.of(PageTable.of(leafPage()));
+ var body = sidecarBody(html, "juneau-page:admin");
+ var meta = Json.to(body, Map.class);
+ assertEquals(PageDef.CONTRACT_VERSION, meta.get("contractVersion"));
+ }
+
+ @Test void b06_pageMetaScriptBreakoutIsNeutralized() {
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("t", "x</script><!-- <script>alert(1)</script>").view(view("v")))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ var body = sidecarBody(html, "juneau-page:admin");
+ assertFalse(body.contains("<"), () -> "raw '<' leaked into the PAGE_META sidecar:\n" + body);
+ assertTrue(body.contains("\\u003c"), () -> "expected \\u003c escaping in PAGE_META sidecar:\n" + body);
+ // Still valid, round-trippable JSON carrying the original label.
+ var meta = Json.to(body, Map.class);
+ var tabs = (List<?>) meta.get("tabs");
+ assertEquals("x</script><!-- <script>alert(1)</script>", ((Map<?,?>) tabs.get(0)).get("label"));
+ }
+
+ @Test void b07_hashHrefsUseIdsNotLabels() {
+ var html = Html.of(PageTable.of(pageWithSubtabs()));
+ assertTrue(html.contains("href=\"#admin/releases\""), html);
+ assertTrue(html.contains("href=\"#admin/catalog/packages\""), html);
+ assertTrue(html.contains("href=\"#admin/catalog/bundles\""), html);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java
new file mode 100644
index 0000000000..5a99fff2ff
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java
@@ -0,0 +1,302 @@
+/*
+ * 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.*;
+
+/**
+ * Unified paging-pill construction/wiring function assertions for {@code juneau-views.js} (DataTables ribbon
+ * visual-parity design doc §4.C, plan Tasks 8/9/10). Option-A (content-substring + function-body-extraction)
+ * 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.
+ */
+class PagingPill_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);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Task 8 - pure pillState(pageInfo, pageLength)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_pillState_firstAndLastPageDisabledConditions() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function pillState(");
+ assertTrue(fnBody.contains("page === 0"), fnBody);
+ assertTrue(fnBody.contains("page === pages - 1"), fnBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Task 9 - buildPagingPill(viewDef, ctx) DOM construction + wiring
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_buildPagingPill_wiresDataTablesPagingApiSurface() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("function buildPagingPill("), body);
+ assertTrue(body.contains(".page.len("), body);
+ assertTrue(body.contains(".page(\"first\")"), body);
+ assertTrue(body.contains(".page(\"previous\")"), body);
+ assertTrue(body.contains(".page(\"next\")"), body);
+ assertTrue(body.contains(".page(\"last\")"), body);
+ assertTrue(body.contains("\"draw.dt\""), body);
+ assertTrue(body.contains("juneau-view-pagingpill"), body);
+ assertTrue(body.contains("juneau-view-pagingpill-btn"), body);
+ // The unified paging ribbon's central segment is a page-size MENU BUTTON now - the old standalone
+ // <select> is gone (Task: consolidate page-size + nav into one control).
+ assertFalse(body.contains("juneau-view-pagingpill-select"), body);
+ assertTrue(body.contains("juneau-view-pagingpill-menubtn"), body);
+ assertTrue(body.contains("juneau-view-pagingpill-menu"), body);
+ // buildPagingPill returns the bare pill element - it no longer inserts its own row/moves the native info
+ // node (that node doesn't exist any more: buildOptions() now sets `info: false`); buildToolbarRow(...)
+ // owns all toolbar-row DOM insertion (paging pill placement regression, top-toolbar-row fix).
+ var fnBody = functionBody(body, "function buildPagingPill(");
+ assertTrue(fnBody.trim().endsWith("return pill;"), fnBody);
+ assertFalse(fnBody.contains("juneau-view-pagingpill-row"), fnBody);
+ assertFalse(fnBody.contains(".dataTables_info"), fnBody);
+ // Segment order: First, Prev, the page-size menu, Next, Last - a SINGLE unified segmented control.
+ var firstIdx = fnBody.indexOf("pill.appendChild(firstBtn)");
+ var prevIdx = fnBody.indexOf("pill.appendChild(prevBtn)");
+ var menuIdx = fnBody.indexOf("pill.appendChild(sizeMenu.el)");
+ var nextIdx = fnBody.indexOf("pill.appendChild(nextBtn)");
+ var lastIdx = fnBody.indexOf("pill.appendChild(lastBtn)");
+ assertTrue(firstIdx >= 0 && prevIdx > firstIdx && menuIdx > prevIdx && nextIdx > menuIdx && lastIdx > nextIdx, fnBody);
+ }
+
+ @Test void b03_buildPagingPill_carriesPagingDataTestId() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function buildPagingPill(");
+ assertTrue(fnBody.contains("pill.setAttribute(\"data-testid\", \"paging\")"), fnBody);
+ }
+
+ /**
+ * The page-size menu (formerly a standalone {@code <select>}) is a proper WAI-ARIA menu button: {@code
+ * aria-haspopup="listbox"}/{@code aria-expanded} on the button, {@code role="option"} entries with {@code
+ * aria-selected} reflecting the current DataTables page length, keyboard-operable (Enter/Space/ArrowDown
+ * opens; ArrowUp/ArrowDown move; Enter/Space selects; Escape closes and returns focus to the button), and
+ * selecting an option drives the SAME {@code page.len(n).draw()} API the old {@code <select>} used.
+ */
+ @Test void b04_buildPageSizeMenu_isAnAccessibleKeyboardOperableMenuButton() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("function buildPageSizeMenu("), body);
+ var fnBody = functionBody(body, "function buildPageSizeMenu(");
+ assertTrue(fnBody.contains("\"aria-haspopup\", \"listbox\""), fnBody);
+ assertTrue(fnBody.contains("\"aria-expanded\""), fnBody);
+ assertTrue(fnBody.contains("\"role\", \"option\""), fnBody);
+ assertTrue(fnBody.contains("\"aria-selected\""), fnBody);
+ assertTrue(fnBody.contains("\"ArrowDown\""), fnBody);
+ assertTrue(fnBody.contains("\"ArrowUp\""), fnBody);
+ assertTrue(fnBody.contains("\"Escape\""), fnBody);
+ assertTrue(fnBody.contains("ctx.dataTable.page.len("), fnBody);
+ assertTrue(fnBody.contains(".focus()"), fnBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Top-toolbar-row regression (paging pill must sit ABOVE the table, in the SAME row as search + ribbon)
+ //------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Regression: the pill previously inserted its own ".juneau-view-pagingpill-row" next to DataTables' native
+ * info node, which (a) put it at the BOTTOM of the table by default, and (b) broke once `info:false` removed
+ * that info node entirely (the pill's insertion fallback appended to the END of the wrapper - still the
+ * bottom). buildToolbarRow(...) now owns ALL toolbar placement: pill + native search + ribbon assembled into
+ * ONE row and inserted as the wrapper's first child, i.e. above the table.
+ */
+ @Test void b02_buildToolbarRow_assemblesPillSearchAndRibbonAboveTheTable() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function buildToolbarRow(");
+ assertTrue(fnBody.contains("pill"), fnBody);
+ assertTrue(fnBody.contains("bar"), fnBody);
+ assertTrue(fnBody.contains(".querySelector(\".dataTables_filter, .dt-search\")"), fnBody);
+ assertTrue(fnBody.contains("insertBefore(row, wrapper.firstChild)"), fnBody);
+ // Control-row layout: a LEFT cluster (pill only) and a RIGHT cluster (search + ribbon + extras).
+ assertTrue(fnBody.contains("juneau-view-toolbar-left"), fnBody);
+ assertTrue(fnBody.contains("juneau-view-toolbar-right"), fnBody);
+ assertTrue(fnBody.contains("left.appendChild(pill)"), fnBody);
+
+ var initBody = functionBody(body, "function initTable(");
+ assertTrue(initBody.contains("buildPagingPill("), initBody);
+ assertTrue(initBody.contains("buildToolbarRow(wrapper, pill, bar)"), initBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Task 10 - paging-pill shape CSS + native-control-hide selectors (DT1+DT2)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_viewsCss_hidesNativeLengthAndPaginationControlsBothDtGenerations() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".dataTables_length, .dt-length, .dataTables_paginate, .dt-paging {"), body);
+ var hideStart = body.indexOf(".dataTables_length, .dt-length, .dataTables_paginate, .dt-paging {");
+ var hideEnd = body.indexOf("}", hideStart);
+ assertTrue(body.substring(hideStart, hideEnd).contains("display: none"), body);
+ }
+
+ @Test void c02_viewsCss_hasPagingPillShapeAndDisabledDim() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-pagingpill {"), body);
+ assertTrue(body.contains(".juneau-view-pagingpill-btn {"), body);
+ assertTrue(body.contains(".juneau-view-pagingpill-btn:disabled { opacity:"), body);
+ }
+
+ /**
+ * Regression: the DataTables info line ("Showing 0 to 0 of 0 entries...") must be disabled AT THE SOURCE
+ * (`info: false` in buildOptions(), juneau-views.js) rather than CSS-hidden - so there must be no
+ * ".dataTables_info"/".dt-info" hide rule left over in the CSS (a leftover rule would be dead code masking the
+ * fact the node no longer exists at all).
+ */
+ @Test void c03_viewsCss_hasNoDeadInfoLineHideRule() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertFalse(body.contains(".dataTables_info, .dt-info {"), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Item 2 - ribbon container must lay its buttons out horizontally (design doc §4.A regression)
+ //------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Regression: the ribbon CONTAINER (the div buildRibbon(...) in juneau-ribbon.js creates with class
+ * "juneau-view-ribbon") shipped with no CSS rule of its own - only ".juneau-view-ribbon-btn" was styled, which
+ * only centers a button's OWN icon and does nothing for its siblings - so buttons rendered one per line
+ * instead of as a horizontal ribbon. Asserts the CSS selector for the container that juneau-ribbon.js actually
+ * emits is present and lays out as a horizontal flex row.
+ */
+ @Test void c04_viewsCss_ribbonContainerIsHorizontalFlexAndMatchesEmittedClassName() throws Exception {
+ var css = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ var js = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(js.contains("bar.className = \"juneau-view-ribbon\";"), js);
+ assertTrue(css.contains(".juneau-view-ribbon {"), css);
+ var start = css.indexOf(".juneau-view-ribbon {");
+ var end = css.indexOf("}", start);
+ var rule = css.substring(start, end);
+ assertTrue(rule.contains("display: flex"), rule);
+ }
+
+ /**
+ * Regression (item 5's visual-feedback fix): a persisted `option`/`optionGroup` toggle (e.g. the release-manager
+ * "dropped-only" filter, `persist(true)`) can be active with NO visible affordance on the button - so a
+ * still-active filter left over from a prior session silently keeps filtering out every row, and looks
+ * identical to a bug. Asserts the neutral `aria-pressed="true"` shape rule exists here (console-ui's chrome.css
+ * layers the themed accent color on the SAME selector).
+ */
+ @Test void c05_viewsCss_hasNeutralAriaPressedShapeForActiveToggle() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-ribbon-btn[aria-pressed=\"true\"] {"), body);
+ }
+
+ /**
+ * Unified paging ribbon: the old standalone page-size {@code <select>} rule is gone; the central segment's
+ * menu-button/popup/option shapes exist instead.
+ */
+ @Test void c06_viewsCss_hasPageSizeMenuShapeAndNoStandaloneSelect() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertFalse(body.contains(".juneau-view-pagingpill-select"), body);
+ assertTrue(body.contains(".juneau-view-pagingpill-menubtn {"), body);
+ assertTrue(body.contains(".juneau-view-pagingpill-menu {"), body);
+ assertTrue(body.contains(".juneau-view-pagingpill-menu-option {"), body);
+ assertTrue(body.contains(".juneau-view-pagingpill-menu-option[aria-selected=\"true\"]"), body);
+ }
+
+ /** data-testid hooks (selector-ambiguity removal): the unified paging ribbon and the right actions ribbon. */
+ @Test void c07_pagingAndRibbonContainers_carryDataTestIdHooks() throws Exception {
+ var viewsBody = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var pillFnBody = functionBody(viewsBody, "function buildPagingPill(");
+ assertTrue(pillFnBody.contains("pill.setAttribute(\"data-testid\", \"paging\")"), pillFnBody);
+
+ var ribbonBody = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ var ribbonFnBody = functionBody(ribbonBody, "function buildRibbon(");
+ assertTrue(ribbonFnBody.contains("bar.setAttribute(\"data-testid\", \"ribbon\")"), ribbonFnBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Control-row layout item 3 - the "n-n of n" paging summary (pure formatThousands/pagingSummaryText + wiring)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_formatThousands_insertsSeparatorsEveryThreeDigits() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function formatThousands(");
+ assertTrue(fnBody.contains("% 3 === 0"), fnBody);
+ }
+
+ @Test void e02_pagingSummaryText_matchesIrsOneBasedInclusiveRangeAndEmptyCase() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function pagingSummaryText(");
+ assertTrue(fnBody.contains("pageInfo.start + 1"), fnBody);
+ assertTrue(fnBody.contains("pageInfo.end"), fnBody);
+ assertTrue(fnBody.contains("formatThousands(total)"), fnBody);
+ assertTrue(fnBody.contains("\"0-0 of 0\""), fnBody);
+ }
+
+ @Test void e03_buildPagingPill_rendersSummaryTextInsideThePageSizeMenu() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var pillFnBody = functionBody(body, "function buildPagingPill(");
+ assertTrue(pillFnBody.contains("sizeMenu.refresh(pagingSummaryText(info)"), pillFnBody);
+ var menuFnBody = functionBody(body, "function buildPageSizeMenu(");
+ assertTrue(menuFnBody.contains("juneau-view-pagingpill-info"), menuFnBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Unified paging ribbon regression - the old right-hand "page-controls-ribbon" (compact, ribbon-styled
+ // prev/next duplicate) is GONE; paging exists in exactly ONE place.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e04_buildCompactPagingRibbon_wasRemoved_pagingExistsInExactlyOnePlace() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertFalse(body.contains("function buildCompactPagingRibbon("), body);
+ assertFalse(body.contains("juneau-view-pagingribbon"), body);
+ var initBody = functionBody(body, "function initTable(");
+ assertFalse(initBody.contains("pagingRibbon"), initBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Control-row layout item 4 - the per-column search row buildColumnSearchRow(...) inserts/wires
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e05_buildColumnSearchRow_insertsHiddenPerColumnInputsWiredToColumnSearch() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function buildColumnSearchRow(");
+ assertTrue(fnBody.contains("juneau-view-columnsearch-row"), fnBody);
+ assertTrue(fnBody.contains("data-testid\", \"col-search-row\""), fnBody);
+ assertTrue(fnBody.contains("juneau-view-columnsearch-input"), fnBody);
+ assertTrue(fnBody.contains("row.style.display = \"none\""), fnBody);
+ assertTrue(fnBody.contains("dt.column(idx).search(input.value).draw()"), fnBody);
+ assertTrue(fnBody.contains("col.searchable !== false"), fnBody);
+
+ // initTable(...) must actually wire ctx.onColumnSearchToggle to show/hide + clear-and-redraw this row -
+ // the root cause of the previously-broken toggle was that nothing ever assigned this callback.
+ var initBody = functionBody(body, "function initTable(");
+ assertTrue(initBody.contains("buildColumnSearchRow("), initBody);
+ assertTrue(initBody.contains("ctx.onColumnSearchToggle = function"), initBody);
+ assertTrue(initBody.contains("dt.columns().search(\"\").draw()"), initBody);
+ }
+}
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
new file mode 100644
index 0000000000..49f70e5a9c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RibbonIcons_Resolution_Test.java
@@ -0,0 +1,72 @@
+/*
+ * 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.*;
+
+/**
+ * Default-icon lookup + {@code resolveButtonIcon(...)} pure-function assertions for {@code juneau-ribbon.js}
+ * (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.
+ */
+class RibbonIcons_Resolution_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);
+ }
+
+ @Test void a01_ribbonJs_hasDefaultIconsMapWithAllSevenEntries() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("DEFAULT_ICONS"), body);
+ assertTrue(body.contains("copy: \"content_copy\""), body);
+ assertTrue(body.contains("csv: \"csv\""), body);
+ assertTrue(body.contains("excel: \"table\""), body);
+ assertTrue(body.contains("pdf: \"picture_as_pdf\""), body);
+ assertTrue(body.contains("refresh: \"refresh\""), body);
+ assertTrue(body.contains("columnSearchToggle: \"manage_search\""), body);
+ assertTrue(body.contains("collapse: \"unfold_less\""), body);
+ }
+
+ @Test void a02_resolveButtonIcon_explicitSymbolWinsOverDefault() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function resolveButtonIcon(");
+ assertTrue(fnBody.contains(".symbol"), fnBody);
+ }
+
+ @Test void a03_resolveButtonIcon_customOptionWithNoSymbolFallsBackToTune() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function resolveButtonIcon(");
+ assertTrue(fnBody.contains("\"tune\""), fnBody);
+ }
+}
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
new file mode 100644
index 0000000000..ce36a61ab1
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
@@ -0,0 +1,342 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.datatables.*;
+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.Dir;
+import org.junit.jupiter.api.*;
+
+/**
+ * Golden-fixture contract test for the {@code VIEW_META} wire format (design doc §6.10).
+ *
+ * <p>
+ * 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.
+ */
+class ViewMeta_Contract_Test extends TestBase {
+
+ /** Row bean whose simple name drives the {@code rowType} diagnostic field. */
+ public static class Release {
+ public String name;
+ public String status;
+ public String date;
+ }
+
+ /**
+ * The frozen §6.10 golden fixture (pretty-printed; whitespace and key order are normalized away by the
+ * structural map comparison below, while the top-level key <b>order</b> is pinned separately).
+ */
+ private static final String EXPECTED_VIEW_META = """
+ {
+ "contractVersion": "2",
+ "id": "releases",
+ "rowType": "Release",
+ "dataMode": "server",
+ "dataUrl": "servlet:/releases/data",
+ "defaultOrder": [ { "data": "date", "dir": "desc" } ],
+ "columns": [
+ { "data": "name", "title": "Name", "orderable": true, "searchable": true,
+ "render": { "id": "linked" }, "href": "servlet:/releases/{id}" },
+ { "data": "status", "title": "Status", "orderable": true, "searchable": true,
+ "render": { "id": "tag", "meta": { "field": "status" } } },
+ { "data": "date", "title": "Date", "orderable": true, "searchable": true,
+ "render": { "id": "date" } }
+ ],
+ "ribbon": [
+ { "type": "export", "buttons": ["copy", "csv"], "optional": ["excel", "pdf"] },
+ { "type": "columnSearchToggle" },
+ { "type": "option", "id": "show-superseded", "title": "Show superseded",
+ "column": "status", "value": "superseded", "persist": true },
+ { "type": "refresh" }
+ ],
+ "rowClassRules": [
+ { "field": "deleted", "op": "eq", "value": true, "class": "row-deleted" },
+ { "field": "error", "op": "present", "class": "row-flagged" }
+ ]
+ }
+ """;
+
+ /** Builds the representative "releases" view from the §5/§6.10 builder API. */
+ private static ViewDef releasesView() {
+ return ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .defaultOrder("date", Dir.DESC)
+ .columns(
+ Column.of("name").title("Name").render("linked").href("servlet:/releases/{id}"),
+ Column.of("status").title("Status").render("tag:status"),
+ Column.of("date").title("Date").render("date"))
+ .ribbon(
+ RibbonAction.export("copy", "csv").optional("excel", "pdf"),
+ RibbonAction.columnSearchToggle(),
+ RibbonAction.option("show-superseded").title("Show superseded").column("status").value("superseded").persist(true),
+ RibbonAction.refresh())
+ .rowClassRule("deleted", Op.EQ, true, "row-deleted")
+ .rowClassRule("error", Op.PRESENT, "row-flagged")
+ .build();
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // The frozen VIEW_META contract
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_releasesView_serializesToFrozenContract() {
+ var json = Json.of(releasesView());
+
+ // Field-by-field structural compare (order- and whitespace-independent): every key/value in the emitted
+ // JSON must match the golden fixture exactly, and no extra keys may leak.
+ var expected = Json.to(EXPECTED_VIEW_META, Map.class);
+ var actual = Json.to(json, Map.class);
+ assertEquals(expected, actual, json);
+ }
+
+ @Test void a02_topLevelKeyOrderMatchesContract() {
+ var actual = Json.to(Json.of(releasesView()), Map.class);
+ assertEquals(
+ List.of("contractVersion", "id", "rowType", "dataMode", "dataUrl", "defaultOrder", "columns", "ribbon", "rowClassRules"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ @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")) {
+ var key = "\"" + k + "\"";
+ assertFalse(json.contains(key), () -> "Reserved field leaked into VIEW_META: " + k + "\n" + json);
+ }
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // rowClassRules grammar invariants (§6.3)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_rowClassRuleEqRequiresValue() {
+ var e = assertThrows(IllegalArgumentException.class,
+ () -> ViewDef.create("x").rowClassRule("deleted", Op.EQ, null, "row-deleted"));
+ assertTrue(e.getMessage().contains("value"), e::getMessage);
+ }
+
+ @Test void b02_rowClassRulePresentOmitsValue() {
+ var v = ViewDef.create("x")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/x/data")
+ .columns(Column.of("name").title("Name"))
+ .rowClassRule("error", Op.PRESENT, "row-flagged")
+ .build();
+ var json = Json.of(v);
+ // A present/absent rule tests only field presence, so the "value" key must be omitted for that rule.
+ assertTrue(json.contains("\"op\":\"present\""), json);
+ assertFalse(json.contains("\"value\""), json);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // B.2: render-id string sugar (§6.6) - "id:field" -> {id, meta:{field}}
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_renderSugarTagField() {
+ // Everything after the first colon becomes meta.field.
+ var json = Json.of(Column.of("status").render("tag:status"));
+ assertTrue(json.contains("\"render\":{\"id\":\"tag\",\"meta\":{\"field\":\"status\"}}"), json);
+ }
+
+ @Test void c02_renderSugarBareIdOmitsMeta() {
+ // A bare id (no colon) yields a metadata-free renderer -> no "meta" key.
+ var json = Json.of(Column.of("date").render("date"));
+ assertTrue(json.contains("\"render\":{\"id\":\"date\"}"), json);
+ assertFalse(json.contains("\"meta\""), json);
+ }
+
+ @Test void c03_renderSugarEqualsCanonicalForm() {
+ // The string sugar must serialize identically to the hand-built canonical Render.
+ var sugar = Json.of(Column.of("status").render("tag:status"));
+ var canonical = Json.of(Column.of("status").render(Render.of("tag").meta("field", "status")));
+ assertEquals(canonical, sugar);
+ }
+
+ @Test void c04_renderSugarFieldMayContainColons() {
+ // Only the FIRST colon is the id/field delimiter; later colons stay in meta.field.
+ var json = Json.of(Column.of("t").render("ts-zulu:created:at"));
+ assertTrue(json.contains("\"render\":{\"id\":\"ts-zulu\",\"meta\":{\"field\":\"created:at\"}}"), json);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // B.2: column auto-seed from DataTablesColumns.of(rowType) when .columns(...) is omitted
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c10_columnsAutoSeedFromRowType() {
+ var v = ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .build(); // NOTE: no .columns(...) -> should auto-seed from DataTablesColumns.of(Release.class).
+
+ var expected = DataTablesColumns.of(Release.class);
+ assertNotNull(v.columns, "columns should be auto-seeded from the row type when omitted");
+ assertEquals(expected.size(), v.columns.size(), () -> "auto-seeded column count mismatch: " + Json.of(v.columns));
+ for (var i = 0; i < expected.size(); i++) {
+ var e = expected.get(i);
+ var c = v.columns.get(i);
+ assertEquals(e.get("data"), c.data, "data[" + i + "]");
+ assertEquals(e.get("title"), c.title, "title[" + i + "]");
+ assertEquals(e.get("orderable"), c.orderable, "orderable[" + i + "]");
+ assertEquals(e.get("searchable"), c.searchable, "searchable[" + i + "]");
+ }
+ }
+
+ @Test void c11_autoSeedTitlesMatchHumanizedNames() {
+ // Release{name,status,date} -> humanized titles, in Juneau bean-property order. For a public-field bean with
+ // no explicit ordering, Juneau emits properties alphabetically (Date, Name, Status) -- the auto-seed mirrors
+ // DataTablesColumns.of() faithfully, so the order matches that source of truth rather than declaration order.
+ var v = ViewDef.create("releases").rowType(Release.class).dataMode(DataMode.SERVER).dataUrl("u").build();
+ var titles = v.columns.stream().map(c -> c.title).toList();
+ assertEquals(List.of("Date", "Name", "Status"), titles);
+ }
+
+ @Test void c12_explicitColumnsAreNotOverwrittenByAutoSeed() {
+ // When columns are declared explicitly, auto-seed must NOT clobber them.
+ var v = ViewDef.create("x")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("u")
+ .columns(Column.of("name").title("Name"))
+ .build();
+ assertEquals(1, v.columns.size());
+ assertEquals("name", v.columns.get(0).data);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Gap-closing branch coverage: Render null-guards + repeated meta(), RowClassRule NE/ABSENT + misuse guards,
+ // ViewDef.create()/build()/queryableSettings() validation branches, Column setter instruction gaps.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_renderParseNullThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> Render.parse(null));
+ assertTrue(e.getMessage().contains("null"), e::getMessage);
+ }
+
+ @Test void d02_renderParseBlankThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> Render.parse(" "));
+ assertTrue(e.getMessage().contains("blank"), e::getMessage);
+ }
+
+ @Test void d03_renderMetaAppendsSecondEntryToExistingMap() {
+ // First .meta() call creates the map (meta==null branch); a second call must append rather than replace it.
+ var r = Render.of("tag").meta("field", "status").meta("extra", "x");
+ assertEquals(2, r.meta.size());
+ assertEquals("status", r.meta.get("field"));
+ assertEquals("x", r.meta.get("extra"));
+ }
+
+ @Test void d04_rowClassRuleNeUsage() {
+ var r = RowClassRule.of("status", Op.NE, "active", "row-inactive");
+ assertEquals("ne", r.op);
+ assertEquals("active", r.value);
+ }
+
+ @Test void d05_rowClassRuleAbsentUsage() {
+ var r = RowClassRule.of("error", Op.ABSENT, "row-ok");
+ assertEquals("absent", r.op);
+ assertNull(r.value);
+ }
+
+ @Test void d06_rowClassRuleValueFormRejectsPresenceOp() {
+ // Misusing the (field, op, value, class) form with a presence-based op must be rejected.
+ var e = assertThrows(IllegalArgumentException.class,
+ () -> RowClassRule.of("error", Op.PRESENT, "x", "row-flagged"));
+ assertTrue(e.getMessage().contains("does not take a value"), e::getMessage);
+ }
+
+ @Test void d07_rowClassRulePresenceFormRejectsValueOp() {
+ // Misusing the (field, op, class) form with a value-based op must be rejected.
+ var e = assertThrows(IllegalArgumentException.class,
+ () -> RowClassRule.of("status", Op.EQ, "row-x"));
+ assertTrue(e.getMessage().contains("requires a value"), e::getMessage);
+ }
+
+ @Test void d08_viewDefCreateNullIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ViewDef.create(null));
+ assertTrue(e.getMessage().contains("null"), e::getMessage);
+ }
+
+ @Test void d09_viewDefCreateBlankIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ViewDef.create(" "));
+ assertTrue(e.getMessage().contains("blank"), e::getMessage);
+ }
+
+ @Test void d10_buildWithoutRowTypeOrColumns_leavesColumnsNull() {
+ // Neither .rowType(...) nor .columns(...) declared -> build() has nothing to auto-seed from.
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").build();
+ assertNull(v.columns);
+ }
+
+ @Test void d11_queryableSettingsWithoutRowTypeThrows() {
+ var e = assertThrows(IllegalArgumentException.class,
+ () -> ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").build().queryableSettings());
+ assertTrue(e.getMessage().contains("rowType"), e::getMessage);
+ }
+
+ @Test void d12_columnNameSetter() {
+ var c = Column.of("status").name("statusCol");
+ assertEquals("statusCol", c.name);
+ }
+
+ @Test void d13_columnClassNameSetter() {
+ var c = Column.of("status").className("text-right");
+ assertEquals("text-right", c.className);
+ }
+
+ @Test void d14_ribbonActionDividerAndHintSetters() {
+ var divider = RibbonAction.divider();
+ assertEquals("divider", divider.type);
+
+ var withHints = RibbonAction.option("o").symbol("*").color("red").deselectable();
+ assertEquals("*", withHints.symbol);
+ assertEquals("red", withHints.color);
+ assertTrue(withHints.deselectable);
+ }
+
+ @Test void d15_ribbonActionOptHintSetters() {
+ var opt = RibbonAction.Opt.of("a").title("A").color("blue").symbol("o");
+ assertEquals("A", opt.title);
+ assertEquals("blue", opt.color);
+ assertEquals("o", opt.symbol);
+ }
+
+ @Test void d16_ribbonActionGroupSetter_andOmittedWhenUnset() {
+ // The "group" affordance (visual-parity control-row layout: segmented ribbon clusters) - set + serialized.
+ var grouped = RibbonAction.columnSearchToggle().group("filters");
+ assertEquals("filters", grouped.group);
+ assertTrue(Json.of(grouped).contains("\"group\":\"filters\""), Json.of(grouped));
+
+ // Unset (the common case) -> omitted entirely, like every other optional RibbonAction field.
+ var ungrouped = RibbonAction.columnSearchToggle();
+ assertNull(ungrouped.group);
+ assertFalse(Json.of(ungrouped).contains("\"group\""), Json.of(ungrouped));
+ }
+}
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
new file mode 100644
index 0000000000..c098256f30
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewServerWiring_Test.java
@@ -0,0 +1,280 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.config.*;
+import org.apache.juneau.rest.server.converter.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Server-side wiring + ribbon-toggle→query-arg mapping tests (design doc §6, Task B.5).
+ *
+ * <p>
+ * Two seams are proven here:
+ * <ul class='spaced-list'>
+ * <li>The <b>pure</b> {@link RibbonAction#toQueryParams(ViewDef)} mapping: a column-scoped {@code option} contributes
+ * the DataTables {@code columns[N][search][value]} request param (resolving the column key to its index), and a
+ * custom-param {@code option} contributes its own {@code param=value} (which the endpoint, not
+ * {@code DataTablesQueryProtocol}, must parse).
+ * <li>The <b>end-to-end</b> server wiring: a view's {@link ViewDef#queryableSettings()} helper registers the
+ * {@code DataTablesQueryProtocol(rowType)} so a {@link ProtocolQueryable}-converted op returns a
+ * {@code DataTablesResults} envelope with the ribbon toggle's filter applied — the same
+ * {@code columns[1][search][value]} key the pure mapping emits.
+ * </ul>
+ */
+class ViewServerWiring_Test extends TestBase {
+
+ /** Row bean: {@code name} (col 0), {@code status} (col 1), {@code date} (col 2). */
+ public static class Release {
+ public String name;
+ public String status;
+ public String date;
+ public Release() {}
+ public Release(String name, String status, String date) { this.name = name; this.status = status; this.date = date; }
+ }
+
+ /** The representative "releases" view with a column-scoped status quick-filter option ({@code status} = index 1). */
+ private static ViewDef releasesView() {
+ return ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .columns(
+ Column.of("name").title("Name"),
+ Column.of("status").title("Status"),
+ Column.of("date").title("Date"))
+ .ribbon(
+ RibbonAction.columnSearchToggle(),
+ RibbonAction.option("show-superseded").title("Show superseded").column("status").value("superseded").persist(true),
+ RibbonAction.refresh())
+ .build();
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Step 1: pure column-scoped mapping -> columns[N][search][value]
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_columnScopedOption_mapsToColumnSearchValueByIndex() {
+ var params = RibbonAction.toQueryParams(releasesView());
+ // status is column index 1, so the "show-superseded" toggle contributes columns[1][search][value]=superseded.
+ assertEquals("superseded", params.get("columns[1][search][value]"));
+ }
+
+ @Test void a02_nonOptionActions_contributeNothing() {
+ // columnSearchToggle/refresh are not query-contributing options -> the only mapped key is the option's.
+ var params = RibbonAction.toQueryParams(releasesView());
+ assertEquals(1, params.size(), () -> "unexpected params: " + params);
+ }
+
+ @Test void a03_unknownColumn_isRejected() {
+ var bad = ViewDef.create("x")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/x/data")
+ .columns(Column.of("name").title("Name"))
+ .ribbon(RibbonAction.option("o").column("nope").value("v"))
+ .build();
+ var e = assertThrows(IllegalArgumentException.class, () -> RibbonAction.toQueryParams(bad));
+ assertTrue(e.getMessage().contains("nope"), e::getMessage);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Step 2: non-column-scoped custom param -> param=value (endpoint's responsibility to parse)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_customParamOption_mapsToOwnParam() {
+ var v = ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .columns(Column.of("name").title("Name"))
+ .ribbon(RibbonAction.option("include-deleted").param("includeDeleted").value("true"))
+ .build();
+ var params = RibbonAction.toQueryParams(v);
+ assertEquals("true", params.get("includeDeleted"));
+ // It must NOT be shaped as a DataTables column-search param.
+ assertFalse(params.keySet().stream().anyMatch(k -> k.startsWith("columns[")), () -> "params: " + params);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Step 3/4: end-to-end server wiring via ViewDef.queryableSettings() -> DataTablesResults envelope
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Rest(converters=ProtocolQueryable.class)
+ public static class ReleasesHost implements BasicUniversalConfig {
+ static final ViewDef VIEW = releasesView();
+ @RestOp(path="/data")
+ public List<Release> data() {
+ return List.of(
+ new Release("1.0", "released", "2024-01-01"),
+ new Release("2.0", "superseded", "2023-01-01"),
+ new Release("3.0", "superseded", "2022-01-01"),
+ new Release("4.0", "draft", "2025-01-01"));
+ }
+ /** Server-side wiring helper: keeps the protocol's rowType in sync with the ViewDef. */
+ @Bean public QueryableSettings queryableSettings() {
+ return VIEW.queryableSettings();
+ }
+ }
+
+ /** The full DataTables column-descriptor block for the (name, status, date) view. */
+ private static String[] cols() {
+ return new String[] {
+ "columns[0][data]", "name", "columns[0][searchable]", "true", "columns[0][orderable]", "true",
+ "columns[1][data]", "status", "columns[1][searchable]", "true", "columns[1][orderable]", "true",
+ "columns[2][data]", "date", "columns[2][searchable]", "true", "columns[2][orderable]", "true"
+ };
+ }
+
+ private static String qs(String...kv) {
+ var parts = new ArrayList<String>();
+ for (var i = 0; i < kv.length; i += 2)
+ parts.add(enc(kv[i]) + "=" + enc(kv[i+1]));
+ return String.join("&", parts);
+ }
+
+ private static String enc(String s) {
+ return URLEncoder.encode(s, StandardCharsets.UTF_8);
+ }
+
+ @Test void c01_ribbonToggle_filtersServerSide_returningEnvelope() throws Exception {
+ var c = MockRestClient.buildJson(ReleasesHost.class);
+
+ // Derive the exact search key/value the ribbon toggle contributes (proves the pure mapping drives the wire).
+ var toggle = RibbonAction.toQueryParams(ReleasesHost.VIEW);
+ var key = "columns[1][search][value]";
+ assertEquals("superseded", toggle.get(key));
+
+ var kv = new ArrayList<>(List.of("draw", "1", "start", "0", "length", "10"));
+ kv.addAll(List.of(cols()));
+ kv.addAll(List.of(key, toggle.get(key)));
+
+ var body = c.get("/data?" + qs(kv.toArray(String[]::new))).run().cacheContent().assertStatus(200).getContent().asString();
+
+ // Envelope shape: {draw, recordsTotal, recordsFiltered, data}.
+ assertTrue(body.contains("\"draw\":1"), body);
+ assertTrue(body.contains("\"recordsTotal\":4"), body);
+ assertTrue(body.contains("\"recordsFiltered\":2"), body);
+ assertTrue(body.contains("\"data\":["), body);
+ // Only the two superseded releases survive the column filter.
+ assertTrue(body.contains("2.0") && body.contains("3.0"), body);
+ assertFalse(body.contains("1.0") || body.contains("4.0"), body);
+ }
+
+ @Test void c02_queryableSettings_carriesDataTablesProtocol() {
+ // The helper must yield a settings bean whose protocol is the DataTables adapter (not the native default).
+ var s = releasesView().queryableSettings();
+ assertNotNull(s);
+ assertNotSame(QueryableSettings.DEFAULT.protocol(), s.protocol());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Step 4: gap-closing branch coverage for toQueryParams() edge cases (design doc §6.5)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_nullRibbon_contributesNothing() {
+ // No .ribbon(...) declared -> ribbon stays null; toQueryParams must short-circuit, not NPE.
+ var v = ViewDef.create("x")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/x/data")
+ .columns(Column.of("name").title("Name"))
+ .build();
+ var params = RibbonAction.toQueryParams(v);
+ assertTrue(params.isEmpty(), () -> "params: " + params);
+ }
+
+ @Test void d02_optionGroupWithMembers_mapsColumnAndParamOptions() {
+ // optionGroup member options are mapped uniformly with top-level options (both column- and param-scoped).
+ var v = ViewDef.create("x")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/x/data")
+ .columns(Column.of("name").title("Name"), Column.of("status").title("Status"))
+ .ribbon(RibbonAction.optionGroup("g")
+ .options(
+ RibbonAction.Opt.of("a").column("status").value("released"),
+ RibbonAction.Opt.of("b").param("custom").value("v2")))
+ .build();
+ var params = RibbonAction.toQueryParams(v);
+ assertEquals("released", params.get("columns[1][search][value]"));
+ assertEquals("v2", params.get("custom"));
+ assertEquals(2, params.size(), () -> "params: " + params);
+ }
+
+ @Test void d03_optionGroupWithNoOptions_contributesNothing() {
+ // No .options(...) declared on the optionGroup -> options stays null; must be skipped, not NPE.
+ var v = ViewDef.create("x")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/x/data")
+ .columns(Column.of("name").title("Name"))
+ .ribbon(RibbonAction.optionGroup("g"))
+ .build();
+ var params = RibbonAction.toQueryParams(v);
+ assertTrue(params.isEmpty(), () -> "params: " + params);
+ }
+
+ @Test void d04_optionWithNoValue_contributesNothing() {
+ // A column-scoped option with no .value(...) set is a no-op (valueless options contribute nothing).
+ var v = ViewDef.create("x")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/x/data")
+ .columns(Column.of("name").title("Name"))
+ .ribbon(RibbonAction.option("o").column("name"))
+ .build();
+ var params = RibbonAction.toQueryParams(v);
+ assertTrue(params.isEmpty(), () -> "params: " + params);
+ }
+
+ @Test void d05_optionWithValueButNoColumnOrParam_contributesNothing() {
+ // A value-bearing option with neither .column(...) nor .param(...) set has nowhere to map -> no-op.
+ var v = ViewDef.create("x")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/x/data")
+ .columns(Column.of("name").title("Name"))
+ .ribbon(RibbonAction.option("o").value("v"))
+ .build();
+ var params = RibbonAction.toQueryParams(v);
+ assertTrue(params.isEmpty(), () -> "params: " + params);
+ }
+
+ @Test void d06_columnScopedOption_withNullColumns_throws() {
+ // No rowType and no .columns(...) declared -> viewDef.columns stays null; resolving a column-scoped option
+ // must still fail loud (not NPE) with the same "unknown column" message as the populated-columns case.
+ var v = ViewDef.create("x")
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/x/data")
+ .ribbon(RibbonAction.option("o").column("foo").value("v"))
+ .build();
+ var e = assertThrows(IllegalArgumentException.class, () -> RibbonAction.toQueryParams(v));
+ assertTrue(e.getMessage().contains("foo"), e::getMessage);
+ }
+}
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
new file mode 100644
index 0000000000..f89ceaf305
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_Emit_Test.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Markup + escaping tests for the {@link ViewTable} emitter (design doc §6.1).
+ *
+ * <p>
+ * Proves the emitter produces the {@code data-juneau-view} table plus the {@code <script type="application/json">}
+ * VIEW_META sidecar, and — the security-critical part — that a {@link ViewDef} whose serialized JSON
+ * contains {@code <script>}/{@code </script>}-shaped strings is neutralized so it cannot break out of the
+ * {@code <script>} element.
+ */
+class ViewTable_Emit_Test extends TestBase {
+
+ /** Row bean for the client-mode {@code tbody} test. */
+ public static class Release {
+ public String name;
+ public String status;
+ public Release(String name, String status) { this.name = name; this.status = status; }
+ }
+
+ private static ViewDef view() {
+ return ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .columns(
+ Column.of("name").title("Name"),
+ Column.of("status").title("Status"))
+ .build();
+ }
+
+ /** Extracts the raw text between the sidecar's opening and closing {@code <script>} tags. */
+ private static String sidecarBody(String html) {
+ var open = html.indexOf("id=\"juneau-view:releases\"");
+ assertTrue(open >= 0, () -> "sidecar script tag not found:\n" + html);
+ var contentStart = html.indexOf('>', open) + 1;
+ var contentEnd = html.indexOf("</script>", contentStart);
+ return html.substring(contentStart, contentEnd);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Markup shape
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_emitsViewTableAndSidecar() {
+ var html = Html.of(ViewTable.of(view()));
+ assertTrue(html.contains("id=\"releases\""), html);
+ assertTrue(html.contains("data-juneau-view=\"releases\""), html);
+ assertTrue(html.contains("<script type=\"application/json\" id=\"juneau-view:releases\">"), html);
+ }
+
+ @Test void a02_usesViewMarkerNotDataTablesMarker() {
+ 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");
+ }
+
+ @Test void a03_sidecarCarriesTheSerializedViewMeta() {
+ var html = Html.of(ViewTable.of(view()));
+ var body = sidecarBody(html);
+ // The sidecar JSON must round-trip back to the same VIEW_META the golden fixture pins.
+ var expected = Json.to(Json.of(view()), Map.class);
+ var actual = Json.to(body, Map.class);
+ assertEquals(expected, actual, body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Escaping contract (SECURITY-CRITICAL) - a </script> in the config must not break out of the <script> element.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_scriptBreakoutIsNeutralized() {
+ var v = ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .columns(Column.of("name").title("x</script><!-- <script>alert(1)</script>"))
+ .build();
+
+ var html = Html.of(ViewTable.of(v));
+ var body = sidecarBody(html);
+
+ // No raw '<' may survive inside the sidecar (so neither </script> nor <!-- nor <script can break out).
+ assertFalse(body.contains("<"), () -> "raw '<' leaked into the JSON sidecar:\n" + body);
+ // The break-out char must be present as its JSON unicode escape.
+ assertTrue(body.contains("\\u003c"), () -> "expected \\u003c escaping in sidecar:\n" + body);
+ }
+
+ @Test void b02_neutralizedPayloadStillRoundTripsAsData() {
+ var payload = "x</script><!-- <script>alert(1)</script>";
+ var v = ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .columns(Column.of("name").title(payload))
+ .build();
+
+ var html = Html.of(ViewTable.of(v));
+ var body = sidecarBody(html);
+
+ // The escaped JSON is still valid JSON and the value decodes back to the original string (data preserved).
+ var parsed = Json.to(body, Map.class);
+ var cols = (List<?>) parsed.get("columns");
+ var col0 = (Map<?,?>) cols.get(0);
+ assertEquals(payload, col0.get("title"), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Optional rows (client mode) -> populated <tbody>
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_rowsOverloadPopulatesTbody() {
+ var rows = List.of(new Release("1.0", "released"), new Release("2.0", "draft"));
+ var html = Html.of(ViewTable.of(view(), rows));
+ assertTrue(html.contains("<tbody>"), html);
+ assertTrue(html.contains("released"), html);
+ assertTrue(html.contains("draft"), html);
+ }
+
+ @Test void c02_noRowsOmitsTbody() {
+ var html = Html.of(ViewTable.of(view()));
+ // Server mode with no rows: rows arrive via ajax, so no <tbody> is emitted up front.
+ assertFalse(html.contains("<tbody>"), html);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Re-serializability (Task 1 regression) - the String-backed rawText sidecar is NOT a one-shot Reader, so the same
+ // ViewTable bean survives repeated serialization (which is what the full HtmlDoc page path requires).
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_sameBeanReserializesByteIdenticalAndKeepsEscaping() {
+ var v = ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .columns(Column.of("name").title("x</script><!-- <script>alert(1)</script>"))
+ .build();
+ var bean = ViewTable.of(v);
+
+ var html1 = Html.of(bean);
+ var html2 = Html.of(bean);
+
+ // Re-serializable: before the rawText swap the sidecar was a one-shot StringReader (consumed on first read).
+ assertFalse(html1.isEmpty(), () -> "first serialization was empty");
+ assertEquals(html1, html2, () -> "same ViewTable bean must re-serialize byte-identically:\n1:" + html1 + "\n2:" + html2);
+
+ // The B.3 escaping contract still holds on the (re)serialized output: no raw '<' survives, \u003c is present.
+ var body = sidecarBody(html2);
+ assertFalse(body.contains("<"), () -> "raw '<' leaked into the JSON sidecar after re-serialization:\n" + body);
+ assertTrue(body.contains("\\u003c"), () -> "expected \\u003c escaping in re-serialized sidecar:\n" + body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Emitter branch coverage (thead title fallback + Map-backed rows with a missing key)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d02_titleFallsBackToDataKeyWhenNoTitleSet() {
+ // A column with no .title(): the <thead> cell falls back to the data key.
+ var v = ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .columns(Column.of("name")) // no .title(...) -> header falls back to the data key
+ .build();
+ var html = Html.of(ViewTable.of(v));
+ assertTrue(html.contains("<th>name</th>"), html);
+ }
+
+ @Test void d03_mapBackedRowsWithMissingKeyRenderEmptyCell() {
+ // Map-backed rows exercise the direct-key-lookup path; an absent key yields an empty cell.
+ var r1 = new LinkedHashMap<String,Object>();
+ r1.put("name", "1.0");
+ r1.put("status", "released");
+ var r2 = new LinkedHashMap<String,Object>();
+ r2.put("name", "2.0"); // no "status" key -> null value -> empty <td>
+ var rows = List.of(r1, r2);
+
+ var html = Html.of(ViewTable.of(view(), rows));
+ assertTrue(html.contains("<tbody>"), html);
+ assertTrue(html.contains("released"), html);
+ assertTrue(html.contains("2.0"), html);
+ }
+}
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
new file mode 100644
index 0000000000..15e50808ce
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
@@ -0,0 +1,64 @@
+/*
+ * 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.junit.jupiter.api.*;
+
+/**
+ * Lazy-init seam test for {@code juneau-views.js} (TODO-399 Phase C, Task 6, design doc §"Client page runtime").
+ *
+ * <p>
+ * There is no JS-execution (jsdom/Option-B) harness in this module yet (confirmed by inspecting
+ * {@code ViewsMixin_Serving_Test}, which is Option-A-only: {@code e03}/{@code g04} assert on served-script content
+ * substrings, not on executed behavior) — so, per that established convention, this test asserts on the
+ * served script text: {@code initTable} must be reachable off the public {@code NS.init} namespace (previously
+ * private), and {@code initAll} must skip tables scoped under a {@code [data-juneau-page]} shell.
+ */
+class ViewsJs_PageSeam_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ @Test void a01_initTableIsPubliclyExposedOnNsInit() throws Exception {
+ var body = c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("initTable: initTable"), body);
+ }
+
+ @Test void a02_initAllSkipsTablesScopedUnderAJuneauPageShell() throws Exception {
+ var body = c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var initAllStart = body.indexOf("function initAll(");
+ assertTrue(initAllStart >= 0, () -> "initAll not found:\n" + body);
+ var initAllEnd = body.indexOf("\n\t}", initAllStart);
+ var initAllBody = body.substring(initAllStart, initAllEnd < 0 ? body.length() : initAllEnd);
+ assertTrue(initAllBody.contains("data-juneau-page"), initAllBody);
+ }
+
+ @Test void a03_initTableRemainsIdempotentGuardIntact() throws Exception {
+ // Regression: the seam must not remove the existing isDataTable idempotent re-entry guard.
+ var body = c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("isDataTable(table)"), body);
+ }
+}
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
new file mode 100644
index 0000000000..04f61f89df
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Pages_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;
+
+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.junit.jupiter.api.*;
+
+/**
+ * Serving + versioned-URL tests for the {@code juneau-pages.js} asset on {@link ViewsMixin} (TODO-399 Phase C,
+ * Task 5).
+ *
+ * <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)}.
+ */
+class ViewsMixin_Pages_Test extends TestBase {
+
+ public static class NoMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items"; }
+ }
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items"; }
+ }
+
+ private static final MockRestClient cNoMixin = MockRestClient.buildLax(NoMixin.class);
+ private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.class);
+
+ @Test void a01_hostWithoutMixin_pagesJsRouteIs404() throws Exception {
+ cNoMixin.get(ViewsMixin.PAGES_JS_PATH).run().assertStatus(404);
+ }
+
+ @Test void a02_pagesJs_served() throws Exception {
+ cWithMixin.get(ViewsMixin.PAGES_JS_PATH).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/javascript")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .assertContent().asString().isContains("juneau-pages.js");
+ }
+
+ @Test void a03_viewAssetUrl_carriesVersionAndContentHashCacheBusterForPagesJs() {
+ var v = ViewsMixin.class.getPackage().getImplementationVersion();
+ var expectedPrefix = "servlet:" + ViewsMixin.PAGES_JS_PATH + "?v=" + (v == null ? "dev" : v) + "-";
+ var url = ViewsMixin.viewAssetUrl(ViewsMixin.PAGES_JS_PATH);
+ assertTrue(url.startsWith(expectedPrefix), url);
+ assertTrue(url.substring(expectedPrefix.length()).matches("[0-9a-f]{8}"), url);
+ }
+
+ @Test void a04_pagesJs_bakesInPageContractVersionHandshake() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.PAGES_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("JUNEAU_PAGE_CONTRACT_VERSION"), body);
+ assertTrue(body.contains("\"" + PageDef.CONTRACT_VERSION + "\""), body);
+ }
+}
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
new file mode 100644
index 0000000000..4b9cf80702
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -0,0 +1,530 @@
+/*
+ * 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 static org.apache.juneau.commons.utils.CollectionUtils.*;
+
+import java.nio.charset.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.bean.html5.Div;
+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.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.apache.juneau.rest.server.views.ViewDef.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Serving smoke + versioned-URL + contract-handshake tests for {@link ViewsMixin} (design doc §6.1, Task B.4).
+ *
+ * <p>
+ * Option-A coverage (mirrors {@code DataTablesMixin}/{@code ConsoleChromeMixin}'s serving tests): a host composing
+ * the mixin exposes each of the four runtime assets at its stable path with a {@code 200} + correct content-type +
+ * {@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.
+ */
+class ViewsMixin_Serving_Test extends TestBase {
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Hosts
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class NoMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items"; }
+ }
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items"; }
+ }
+
+ /** Row bean for the emitted view page. */
+ public static class Rel {
+ public String name;
+ public String status;
+ public String date;
+ }
+
+ /** The §6.10 golden-shape view the page-consumption test emits + round-trips. */
+ static ViewDef releasesView() {
+ return ViewDef.create("releases")
+ .rowType(Rel.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .defaultOrder("date", Dir.DESC)
+ .columns(
+ Column.of("name").title("Name").render("linked").href("servlet:/releases/{name}"),
+ Column.of("status").title("Status").render("tag:status"),
+ Column.of("date").title("Date").render("date"))
+ .ribbon(
+ RibbonAction.export("copy", "csv").optional("excel", "pdf"),
+ RibbonAction.columnSearchToggle(),
+ RibbonAction.option("show-superseded").title("Show superseded").column("status").value("superseded").persist(true),
+ RibbonAction.refresh())
+ .build();
+ }
+
+ // Emits the view the documented way (OD-2(a)): pre-serialize the ViewTable markup and serve it as trusted markup,
+ // rather than returning the DOM bean through the full HtmlDoc page path (the sidecar's one-shot Reader content is
+ // intended to be serialized exactly once - see ViewTable's class javadoc).
+ @Rest(mixins=ViewsMixin.class)
+ public static class ViewHost extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/releases") public HttpResource releasesPage() {
+ var markup = Html.of(ViewTable.of(releasesView()));
+ return HttpResourceBean.of(
+ ByteArrayBody.of(markup.getBytes(StandardCharsets.UTF_8), "text/html;charset=utf-8"),
+ list(ContentType.of("text/html;charset=utf-8")));
+ }
+ }
+
+ // Regression (Task 1): returns the ViewTable Div bean DIRECTLY through the servlet's full HtmlDoc page path.
+ // Before the rawText swap this 500'd because the sidecar's one-shot StringReader content could not survive that
+ // serializer (it worked only via HtmlSerializer/Html.of); the String-backed rawText makes the bean re-serializable.
+ @Rest(mixins=ViewsMixin.class)
+ public static class ViewBeanHost extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/releases") public Div releasesPage() {
+ return ViewTable.of(releasesView());
+ }
+ }
+
+ private static final MockRestClient cNoMixin = MockRestClient.buildLax(NoMixin.class);
+ private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.class);
+ private static final MockRestClient cViewHost = MockRestClient.buildLax(ViewHost.class);
+ private static final MockRestClient cViewBeanHost = MockRestClient.buildLax(ViewBeanHost.class);
+
+ /** 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);
+ }
+
+ /** Extracts the raw text between the sidecar's opening and closing {@code <script>} tags. */
+ private static String sidecarBody(String html) {
+ var open = html.indexOf("id=\"juneau-view:releases\"");
+ assertTrue(open >= 0, () -> "sidecar script tag not found:\n" + html);
+ var contentStart = html.indexOf('>', open) + 1;
+ var contentEnd = html.indexOf("</script>", contentStart);
+ return html.substring(contentStart, contentEnd);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Opt-in / back-compat
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_hostWithoutMixin_assetRoutesAre404() throws Exception {
+ cNoMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(404);
+ cNoMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(404);
+ cNoMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(404);
+ cNoMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(404);
+ }
+
+ @Test void h01_hostWithoutMixin_iconsJsRouteIs404() throws Exception {
+ cNoMixin.get(ViewsMixin.ICONS_JS_PATH).run().assertStatus(404);
+ }
+
+ @Test void a02_hostExistingRoute_unaffectedByMixin() throws Exception {
+ cWithMixin.get("/items").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("items");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Each asset serves 200 + content-type + Cache-Control + a content substring
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_viewsJs_served() throws Exception {
+ cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/javascript")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .assertContent().asString().isContains("juneau-views.js");
+ }
+
+ @Test void b02_ribbonJs_served() throws Exception {
+ cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/javascript")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .assertContent().asString().isContains("juneau-ribbon.js");
+ }
+
+ @Test void b03_rendersJs_served() throws Exception {
+ cWithMixin.get(ViewsMixin.RENDERS_JS_PATH).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/javascript")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .assertContent().asString().isContains("juneau-renders.js");
+ }
+
+ @Test void b04_viewsCss_served() throws Exception {
+ cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/css")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .assertContent().asString().isContains("juneau-views.css");
+ }
+
+ @Test void b05_arbitraryVersionQueryString_stillServes200() throws Exception {
+ // The ?v=... cache-buster is a browser cache key, not part of mixin routing - any value still resolves.
+ cWithMixin.get(ViewsMixin.VIEWS_JS_PATH + "?v=whatever").run().assertStatus(200);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) Versioned asset URL (?v=<buildVersion>) via the viewAssetUrl(...) helper
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_viewAssetUrl_carriesVersionAndContentHashCacheBuster() {
+ var v = ViewsMixin.class.getPackage().getImplementationVersion();
+ var expectedPrefix = "servlet:" + ViewsMixin.VIEWS_JS_PATH + "?v=" + (v == null ? "dev" : v) + "-";
+ var url = ViewsMixin.viewAssetUrl(ViewsMixin.VIEWS_JS_PATH);
+ assertTrue(url.startsWith(expectedPrefix), url);
+ assertTrue(url.substring(expectedPrefix.length()).matches("[0-9a-f]{8}"), url);
+ }
+
+ @Test void c02_viewAssetUrl_worksForEveryAssetPath() {
+ for (var path : new String[]{ViewsMixin.VIEWS_JS_PATH, ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH, ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.ICONS_JS_PATH})
+ assertTrue(ViewsMixin.viewAssetUrl(path).contains("?v="), path);
+ }
+
+ @Test void c03_viewAssetUrl_contentHash_isStableAcrossCalls_andDiffersAcrossDistinctAssets() {
+ // The content-hash cache-buster (Task 1) is computed once from each asset's own bytes - it must be stable
+ // across repeated calls (not recomputed per-call) and must differ between distinct assets.
+ var url1 = ViewsMixin.viewAssetUrl(ViewsMixin.VIEWS_JS_PATH);
+ var url2 = ViewsMixin.viewAssetUrl(ViewsMixin.VIEWS_JS_PATH);
+ assertEquals(url1, url2, "hash must be stable across repeated calls");
+ assertNotEquals(url1, ViewsMixin.viewAssetUrl(ViewsMixin.RIBBON_JS_PATH), "distinct assets must not collide on their content hash");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Contract-version handshake constant (single source of truth = ViewDef.CONTRACT_VERSION)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_contractVersion_equalsViewDefContractVersion() {
+ assertEquals(ViewDef.CONTRACT_VERSION, ViewsMixin.CONTRACT_VERSION);
+ assertEquals("2", ViewsMixin.CONTRACT_VERSION);
+ }
+
+ @Test void d02_viewsJs_bakesInContractVersionHandshake() throws Exception {
+ // The initializer must carry the handshake constant so the client can fail-loud on a contractVersion mismatch.
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("JUNEAU_VIEW_CONTRACT_VERSION"), body);
+ assertTrue(body.contains("\"" + ViewsMixin.CONTRACT_VERSION + "\""), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) B.6 - juneau-renders.js registry + base juneau-views.css .tag chip
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_rendersJs_hasRegistryAndGenericRenderers() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(200).getContent().asString();
+ // Concrete API surface (call sites / registered ids), not just the header-comment prose.
+ assertTrue(body.contains("registerRenderer("), body);
+ assertTrue(body.contains("parseRenderId("), body);
+ assertTrue(body.contains("\"ts-zulu\""), body); // the ts-zulu renderer is actually registered
+ }
+
+ @Test void e02_viewsCss_hasNeutralTagChip() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ // A real base-chip rule (not just the header comment): neutral shape/padding, no colors.
+ assertTrue(body.contains(".tag {"), body);
+ assertTrue(body.contains("border-radius:"), body);
+ }
+
+ @Test void e04_viewsCss_hasNeutralRibbonButtonShape() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-ribbon-btn {"), body);
+ assertTrue(body.contains("width: 32px"), body);
+ assertTrue(body.contains("height: 32px"), body);
+ assertTrue(body.contains("display: flex"), body);
+ assertTrue(body.contains("border: 1px solid"), body);
+ assertTrue(body.contains(".juneau-view-ribbon-btn svg {"), body);
+ assertTrue(body.contains("fill: currentColor"), body);
+ }
+
+ @Test void e03_rendersJs_tagRendererNormalizesCssTokenToMatchConsoleUi() throws Exception {
+ // Regression: the `tag` renderer must mirror console-ui's Tag#normalize/TagHtmlRender token algorithm
+ // (lowercase both <domain> and <value> into the `.tag.<domain>.<value>` CSS token) so themed chrome.css
+ // rules (e.g. `.tag.status.released`) match - a raw "RELEASED" cell must no longer render as an
+ // unthemed neutral chip. Option-A (content substring) coverage; true JS-execution verification of the
+ // exact lowercased/hyphenated output awaits the deferred Option-B (jsdom) harness.
+ var body = cWithMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("normalizeTagToken("), body);
+ var tagRendererStart = body.indexOf("registerRenderer(\"tag\"");
+ assertTrue(tagRendererStart >= 0, () -> "tag renderer not found:\n" + body);
+ var tagRendererEnd = body.indexOf("registerRenderer(", tagRendererStart + 1);
+ var tagRendererRegion = body.substring(tagRendererStart, tagRendererEnd < 0 ? body.length() : tagRendererEnd);
+ assertTrue(tagRendererRegion.contains("normalizeTagToken(domain)"), tagRendererRegion);
+ assertTrue(tagRendererRegion.contains("normalizeTagToken(value)"), tagRendererRegion);
+ // The RAW value (not the normalized token) must still be what the user sees as display text.
+ assertTrue(tagRendererRegion.contains("escHtml(value)"), tagRendererRegion);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) B.7 - juneau-ribbon.js runtime (feature-detected export + column-search-value contribution)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_ribbonJs_hasFeatureDetectedExportAndParamContribution() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("Buttons"), body); // feature-detect DataTables Buttons
+ assertTrue(body.contains("feature"), body); // feature-detection markers
+ assertTrue(body.contains("columns["), body); // mirrors RibbonAction.toQueryParams' columns[N][search][value]
+ }
+
+ @Test void f02_ribbonJs_rendersIconOnlyButtonsWithAriaLabel() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("setAttribute(\"aria-label\""), body);
+ assertTrue(body.contains(".title = "), body);
+ assertTrue(body.contains("resolveButtonIcon("), body);
+ assertTrue(body.contains("NS.icons.resolveIcon("), body);
+ var buttonFnStart = body.indexOf("function button(");
+ assertTrue(buttonFnStart >= 0, () -> "function button( not found:\n" + body);
+ var buttonFnEnd = body.indexOf("\n\t}", buttonFnStart);
+ var buttonFnBody = body.substring(buttonFnStart, buttonFnEnd < 0 ? body.length() : buttonFnEnd);
+ assertFalse(buttonFnBody.contains("b.textContent = label"), buttonFnBody);
+ }
+
+ @Test void f03_ribbonJs_exportButtonsTriggerDataTablesButtonsApi() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ var exportStart = body.indexOf("a.type === \"export\"");
+ assertTrue(exportStart >= 0, () -> "export branch not found:\n" + body);
+ var exportEnd = body.indexOf("if (a.type ===", exportStart + 1);
+ var exportRegion = body.substring(exportStart, exportEnd < 0 ? body.length() : exportEnd);
+ assertTrue(exportRegion.contains(".button("), exportRegion);
+ assertTrue(exportRegion.contains(".trigger("), exportRegion);
+ assertFalse(exportRegion.contains(".buttons().container().appendTo(bar)"), exportRegion);
+ }
+
+ /**
+ * Control-row layout item 2/5 (single segmented ribbon): the export cluster's buttons, and any two adjacent
+ * actions sharing a {@code group} id, must be wrapped in ONE ".juneau-view-ribbon-group" span via the local
+ * {@code place(el, groupId)} helper - not appended as loose siblings of "bar" (which would render as
+ * individually-bordered buttons rather than one shared-border segmented cluster).
+ */
+ @Test void f04_ribbonJs_groupsExportButtonsAndSharedGroupIdActionsIntoOneSegmentedCluster() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function buildRibbon(");
+ assertTrue(fnBody.contains("function place("), fnBody);
+ assertTrue(fnBody.contains("juneau-view-ribbon-group"), fnBody);
+ // export's own buttons always cluster (synthetic per-index id when no explicit .group is set).
+ assertTrue(fnBody.contains("__export"), fnBody);
+ assertTrue(fnBody.contains("place("), fnBody);
+ // A divider always closes any open cluster (a divider is a deliberate visual break, not a grouping seam).
+ var dividerStart = fnBody.indexOf("a.type === \"divider\"");
+ var dividerEnd = fnBody.indexOf("return;", dividerStart);
+ assertTrue(fnBody.substring(dividerStart, dividerEnd).contains("openGroup = null"), fnBody);
+ }
+
+ /**
+ * Root-cause regression for the broken columnSearchToggle button (control-row layout item 4): the button's
+ * click handler previously flipped `ctx.columnSearchOn` and invoked `ctx.onColumnSearchToggle` if present, but
+ * NOTHING in juneau-views.js ever assigned that callback (grep-confirmed absent before this fix) - so toggling
+ * silently did nothing, and the button never reflected state via `aria-pressed` either (unlike `optionToggle`'s
+ * button, which already did). Asserts the ribbon-side half of the fix: the button's `aria-pressed` is now set
+ * from `toggleColumnSearch(...)`'s return value, both initially and on every click.
+ */
+ @Test void f05_ribbonJs_columnSearchToggleButtonReflectsAriaPressed() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ var branchStart = body.indexOf("a.type === \"columnSearchToggle\"");
+ assertTrue(branchStart >= 0, () -> "columnSearchToggle branch not found:\n" + body);
+ var branchEnd = body.indexOf("if (a.type ===", branchStart + 1);
+ var branch = body.substring(branchStart, branchEnd < 0 ? body.length() : branchEnd);
+ assertTrue(branch.contains("csBtn.setAttribute(\"aria-pressed\""), branch);
+ assertTrue(branch.contains("toggleColumnSearch(viewDef, ctx)"), branch);
+
+ var fnBody = functionBody(body, "function toggleColumnSearch(");
+ assertTrue(fnBody.contains("return ctx.columnSearchOn;"), fnBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) B.8 - juneau-views.js initializer
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_viewsJs_hasInitializerLogic() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("JSON.parse"), body); // reads the sidecar
+ assertTrue(body.contains("serverSide"), body); // wires server-side ajax mode
+ assertTrue(body.contains("createdRow"), body); // applies rowClassRules
+ }
+
+ @Test void g04_viewsJs_columnDefsSetDefaultContentForUndefinedSafety() throws Exception {
+ // Regression: a nullable column's value is OMITTED (not null) by the server's JSON serializer, so
+ // DataTables' data accessor sees `undefined` and throws "Requested unknown parameter" (datatables.net/tn/4)
+ // before any renderer runs. Every generated column def must set defaultContent so DataTables substitutes
+ // that value instead of warning - Option-A (content substring) coverage; true JS-execution verification of
+ // the undefined-safe behavior awaits the deferred Option-B (jsdom) harness.
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var buildColumnDefStart = body.indexOf("function buildColumnDef(");
+ assertTrue(buildColumnDefStart >= 0, () -> "buildColumnDef not found:\n" + body);
+ var buildColumnDefEnd = body.indexOf("\n\t}", buildColumnDefStart);
+ var buildColumnDefBody = body.substring(buildColumnDefStart, buildColumnDefEnd < 0 ? body.length() : buildColumnDefEnd);
+ assertTrue(buildColumnDefBody.contains("defaultContent:"), buildColumnDefBody);
+ }
+
+ @Test void g05_viewsJs_hasSearchLanguagePolishAndPageSizeVocabulary() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("searchPlaceholder"), body);
+ assertTrue(body.contains("\"Search\""), body);
+ assertTrue(body.contains("\"25 rows\""), body);
+ assertTrue(body.contains("\"100 rows\""), body);
+ assertTrue(body.contains("\"All rows\""), body);
+ assertTrue(body.contains("pageLength"), body);
+ }
+
+ @Test void g02_hostPageEmitsSidecarThatRoundTrips() throws Exception {
+ // A ViewsMixin host page that emits a ViewTable carries the marker + sidecar the initializer consumes.
+ var html = cViewHost.get("/releases").accept("text/html").run().assertStatus(200).getContent().asString();
+ assertTrue(html.contains("data-juneau-view=\"releases\""), html);
+ assertTrue(html.contains("id=\"juneau-view:releases\""), html);
+ // The sidecar JSON round-trips back to the same VIEW_META the model emits (structural compare).
+ var body = sidecarBody(html);
+ assertEquals(Json.to(Json.of(releasesView()), java.util.Map.class), Json.to(body, java.util.Map.class), body);
+ }
+
+ @Test void g03_beanReturnedThroughFullHtmlDocPagePath_is200AndReserializable() throws Exception {
+ // The reported bug: returning the ViewTable Div bean through the servlet's full HtmlDoc page path 500'd
+ // because the one-shot StringReader sidecar could not survive that serializer. With rawText it is 200.
+ var html = cViewBeanHost.get("/releases").accept("text/html").run().assertStatus(200).getContent().asString();
+ assertTrue(html.contains("data-juneau-view=\"releases\""), html);
+ assertTrue(html.contains("id=\"juneau-view:releases\""), html);
+
+ // Repeated requests re-emit + re-serialize a fresh bean each time through the page path (not one-shot).
+ var html2 = cViewBeanHost.get("/releases").accept("text/html").run().assertStatus(200).getContent().asString();
+ assertEquals(html, html2, "full-page HtmlDoc output must be stable across repeated serializations");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // h) Ribbon visual-parity Task 1 - juneau-icons.js icon registry
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void h02_iconsJs_served() throws Exception {
+ cWithMixin.get(ViewsMixin.ICONS_JS_PATH).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/javascript")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .assertContent().asString().isContains("juneau-icons.js");
+ }
+
+ @Test void h03_iconsJs_hasRegistryAndBundledGlyphs() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.ICONS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("registerIcon("), body);
+ assertTrue(body.contains("resolveIcon("), body);
+ for (var name : new String[]{
+ "content_copy", "csv", "table", "picture_as_pdf", "refresh", "manage_search", "unfold_less",
+ "first_page", "chevron_left", "chevron_right", "last_page", "tune", "filter_alt", "expand_more"})
+ assertTrue(body.contains("\"" + name + "\""), () -> "missing bundled glyph '" + name + "':\n" + body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // n) Ribbon visual-parity Task 12/13 - responsive cleanup (tab/sub-tab scroll, control-row wrap, toolbar wrapper)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void n01_viewsCss_tabBarScrollsInsteadOfWraps() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ var barStart = body.indexOf(".jc-tab-bar,\n.jc-subtab-bar {");
+ assertTrue(barStart >= 0, () -> ".jc-tab-bar,\\n.jc-subtab-bar { not found:\n" + body);
+ var barEnd = body.indexOf("}", barStart);
+ var barRegion = body.substring(barStart, barEnd);
+ assertTrue(barRegion.contains("flex-wrap: nowrap"), barRegion);
+ assertTrue(barRegion.contains("overflow-x: auto"), barRegion);
+ assertFalse(barRegion.contains("flex-wrap: wrap;"), barRegion);
+
+ var tabStart = body.indexOf(".jc-tab,\n.jc-subtab {");
+ assertTrue(tabStart >= 0, () -> ".jc-tab,\\n.jc-subtab { not found:\n" + body);
+ var tabEnd = body.indexOf("}", tabStart);
+ var tabRegion = body.substring(tabStart, tabEnd);
+ assertTrue(tabRegion.contains("flex: 0 0 auto"), tabRegion);
+ }
+
+ @Test void n02_viewsJs_wrapsRibbonAndSearchInToolbarRow() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("function buildToolbarRow("), body);
+ assertTrue(body.contains("juneau-view-toolbar-row"), body);
+ assertTrue(body.contains(".querySelector(\".dataTables_filter, .dt-search\""), body);
+ }
+
+ @Test void n03_viewsCss_controlRowsWrapAndDividerIsSpacing() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-toolbar-row {"), body);
+ var rowStart = body.indexOf(".juneau-view-toolbar-row {");
+ var rowEnd = body.indexOf("}", rowStart);
+ var rowRegion = body.substring(rowStart, rowEnd);
+ assertTrue(rowRegion.contains("flex-wrap: wrap"), rowRegion);
+ // Control-row layout: LEFT cluster + RIGHT cluster, pushed apart so the right cluster stays right-aligned
+ // (superseded the old single-cluster "justify-content: flex-start").
+ assertTrue(rowRegion.contains("justify-content: space-between"), rowRegion);
+
+ assertTrue(body.contains(".juneau-view-ribbon-divider {"), body);
+ var divStart = body.indexOf(".juneau-view-ribbon-divider {");
+ var divEnd = body.indexOf("}", divStart);
+ var divRegion = body.substring(divStart, divEnd);
+ assertFalse(divRegion.contains("border"), divRegion);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // o) Control-row layout restructure (LEFT page-controls / RIGHT search+ribbon+refresh clusters)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void o01_viewsCss_hasLeftAndRightToolbarClusterShapes() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-toolbar-left,\n.juneau-view-toolbar-right {"), body);
+ var start = body.indexOf(".juneau-view-toolbar-left,\n.juneau-view-toolbar-right {");
+ var end = body.indexOf("}", start);
+ var region = body.substring(start, end);
+ assertTrue(region.contains("display: flex"), region);
+ assertTrue(region.contains("flex-wrap: wrap"), region);
+ }
+
+ /**
+ * Control-row layout item 2/5 (single segmented ribbon): the group wrapper must NOT carry a `gap` (a gap would
+ * leave a visible seam between adjacent buttons instead of a collapsed shared border), and its member buttons
+ * must collapse borders via a negative margin with rounding only on the outer ends.
+ */
+ @Test void o02_viewsCss_ribbonGroupCollapsesSharedBordersRoundedOnlyOnOuterEnds() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-ribbon-group { display: inline-flex; }"), body);
+
+ assertTrue(body.contains(".juneau-view-ribbon-group .juneau-view-ribbon-btn {"), body);
+ var memberStart = body.indexOf(".juneau-view-ribbon-group .juneau-view-ribbon-btn {");
+ var memberEnd = body.indexOf("}", memberStart);
+ var memberRegion = body.substring(memberStart, memberEnd);
+ assertTrue(memberRegion.contains("border-radius: 0"), memberRegion);
+ assertTrue(memberRegion.contains("margin-left: -1px"), memberRegion);
+
+ assertTrue(body.contains(".juneau-view-ribbon-group .juneau-view-ribbon-btn:first-child {"), body);
+ assertTrue(body.contains(".juneau-view-ribbon-group .juneau-view-ribbon-btn:last-child {"), body);
+ }
+
+ @Test void o03_viewsCss_hasNeutralColumnSearchRowAndInputShape() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-columnsearch-row > th {"), body);
+ assertTrue(body.contains(".juneau-view-columnsearch-input {"), body);
+ var start = body.indexOf(".juneau-view-columnsearch-input {");
+ var end = body.indexOf("}", start);
+ assertTrue(body.substring(start, end).contains("border: 1px solid"), body);
+ }
+}
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index 2e7187a9fb..a1ae6e6770 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -47,6 +47,7 @@
<module>juneau-rest-server-tracing-otel</module>
<module>juneau-rest-server-management-logging</module>
<module>juneau-rest-server-datatables</module>
+ <module>juneau-rest-server-views</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>