Skip to content

Commit 6a8257c

Browse files
committed
Basic implementation of view include working
1 parent 35493ac commit 6a8257c

11 files changed

Lines changed: 137 additions & 18 deletions

File tree

groovy/src/main/java/org/kohsuke/stapler/jelly/groovy/JellyBuilder.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ public Object with(XMLOutput out, Closure c) {
469469
* @return null
470470
* if nothing was generated.
471471
*/
472+
// TODO apparently unused
472473
public Element redirectToDom(Closure c) {
473474
SAXContentHandler sc = new SAXContentHandler();
474475
with(new XMLOutput(sc), c);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package org.kohsuke.stapler.html;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
/**
9+
* Denotes that a record field should include another view.
10+
* The field type should be Stapler-dispatchable.
11+
*/
12+
@Target(ElementType.RECORD_COMPONENT)
13+
@Retention(RetentionPolicy.RUNTIME)
14+
public @interface HtmlInclude {
15+
/**
16+
* The name of the view to include.
17+
* No file extension should be used, so for example use {@code index} or {@code config}.
18+
*/
19+
String value();
20+
}

html/src/main/java/org/kohsuke/stapler/html/impl/HtmlClassLoaderTearOff.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public HtmlJellyScript parse(URL script, MetaClass owner) throws Exception {
2525
LOGGER.info(() -> "TODO parsing " + script + " from " + owner);
2626
Class<?> c = owner.klass.toJavaClass();
2727
var base = c.getProtectionDomain().getCodeSource().getLocation()
28-
+ c.getName().replace('.', '/') + "/";
28+
+ c.getName().replace('.', '/').replace('$', '/') + "/";
2929
for (var m : c.getMethods()) {
3030
var ann = m.getAnnotation(HtmlView.class);
3131
if (ann == null) {

html/src/main/java/org/kohsuke/stapler/html/impl/HtmlJellyScript.java

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import edu.umd.cs.findbugs.annotations.CheckForNull;
44
import java.lang.reflect.Method;
5+
import java.util.ArrayList;
56
import java.util.List;
67
import java.util.logging.Logger;
78
import org.apache.commons.jelly.JellyContext;
@@ -10,6 +11,12 @@
1011
import org.apache.commons.jelly.Script;
1112
import org.apache.commons.jelly.XMLOutput;
1213
import org.dom4j.Element;
14+
import org.dom4j.Node;
15+
import org.dom4j.io.SAXContentHandler;
16+
import org.dom4j.io.SAXWriter;
17+
import org.kohsuke.stapler.WebApp;
18+
import org.kohsuke.stapler.html.HtmlInclude;
19+
import org.kohsuke.stapler.jelly.JellyClassTearOff;
1320

1421
final class HtmlJellyScript implements Script {
1522

@@ -34,13 +41,14 @@ public void run(JellyContext context, XMLOutput output) throws JellyTagException
3441
if (it == null) {
3542
throw new JellyTagException("No `it` bound");
3643
}
44+
// TODO set thread name like JellyViewScript does
3745
try {
3846
var record = (Record) method.invoke(it);
3947
LOGGER.info(() -> "TODO " + method + " on " + it + " ⇒ " + record);
4048
// TODO find a more efficient way to render without allocation:
4149
var rendered = (Element) root.clone();
42-
render(rendered, record);
43-
rendered.write(output.asWriter());
50+
render(context, rendered, record);
51+
new SAXWriter(output, output).write(rendered);
4452
} catch (Exception x) {
4553
throw new JellyTagException(x);
4654
}
@@ -51,7 +59,7 @@ var record = (Record) method.invoke(it);
5159
* {@link Visitor} cannot be used easily here because it lacks any way to prune a tree
5260
* or record entry and exit from an element.
5361
*/
54-
private void render(Element rendered, Record record) throws Exception {
62+
private void render(JellyContext context, Element rendered, Record record) throws Exception {
5563
for (var field : record.getClass().getRecordComponents()) {
5664
var id = "st." + field.getName();
5765
var elt = find(rendered, id);
@@ -60,22 +68,47 @@ private void render(Element rendered, Record record) throws Exception {
6068
}
6169
elt.remove(elt.attribute("id"));
6270
var value = field.getAccessor().invoke(record);
63-
if (value instanceof String text) {
64-
elt.setText(text);
71+
var include = field.getAnnotation(HtmlInclude.class);
72+
if (include != null) {
73+
var metaClass = WebApp.getCurrent().getMetaClass(value.getClass());
74+
var script = metaClass.loadTearOff(JellyClassTearOff.class).findScript(include.value());
75+
var subcontext = new JellyContext(context);
76+
subcontext.setExportLibraries(false); // defaults to true, weirdly
77+
subcontext.setVariable("from", value);
78+
subcontext.setVariable("it", value);
79+
var handler = new SAXContentHandler();
80+
var oldCCL = Thread.currentThread().getContextClassLoader();
81+
Thread.currentThread().setContextClassLoader(metaClass.classLoader.loader);
82+
try {
83+
script.run(subcontext, new XMLOutput(handler));
84+
} finally {
85+
Thread.currentThread().setContextClassLoader(oldCCL);
86+
}
87+
// TODO honor JellyFacet.TRACE somehow
88+
replace(elt, List.of(handler.getDocument().getRootElement()));
89+
} else if (value instanceof String text) {
90+
// TODO why do we need to replace entities like this?
91+
// It is not necessary if run calls rendered.write(output.asWriter())
92+
// but that does not seem right because it would be just sending text content, not XML.
93+
// (And then includes do not work at all: the SAXContentHandler is left empty.)
94+
// Also using SAXWriter causes DefaultScriptInvoker.createXMLOutput to use HTMLWriterOutput
95+
// and thus dropping </p>, which does not match what actual Jenkins text/html output is like.
96+
// Retest in the context of Jenkins which might wrap things differently.
97+
elt.setText(text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"));
6598
} else if (value instanceof Boolean enabled) {
6699
if (!enabled) {
67100
elt.detach();
68101
}
69102
} else if (value instanceof Record subrecord) {
70-
render(elt, subrecord);
103+
render(context, elt, subrecord);
71104
} else if (value instanceof List<?> list) {
72-
var parent = elt.getParent();
73-
elt.detach();
105+
var replacements = new ArrayList<Element>();
74106
for (var item : list) {
75107
var elt2 = (Element) elt.clone();
76-
render(elt2, (Record) item);
77-
parent.add(elt2);
108+
render(context, elt2, (Record) item);
109+
replacements.add(elt2);
78110
}
111+
replace(elt, replacements);
79112
} else if (value == null) {
80113
elt.detach();
81114
} else {
@@ -88,7 +121,7 @@ private void render(Element rendered, Record record) throws Exception {
88121
* Like {@link Element#elementByID} except using attribute {@code id} not {@code ID}.
89122
*/
90123
@CheckForNull
91-
private Element find(Element elt, String id) {
124+
private static Element find(Element elt, String id) {
92125
if (id.equals(elt.attributeValue("id"))) {
93126
return elt;
94127
}
@@ -100,4 +133,17 @@ private Element find(Element elt, String id) {
100133
}
101134
return null;
102135
}
136+
137+
// TODO dom4j does not seem to define an insertAt or replace
138+
// also Element.node(int) does not work as documented
139+
private static void replace(Element elt, List<Element> replacements) {
140+
var parent = elt.getParent();
141+
var kids = new ArrayList<Node>();
142+
parent.nodeIterator().forEachRemaining(kids::add);
143+
kids.stream().forEach(Node::detach);
144+
int index = kids.indexOf(elt);
145+
kids.remove(index);
146+
kids.addAll(index, replacements);
147+
kids.stream().forEach(parent::add);
148+
}
103149
}

html/src/test/java/org/kohsuke/stapler/html/HtmlTestCase.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
public abstract class HtmlTestCase extends JettyTestCase {
66

7+
// TODO enable fine logging in subpackages
8+
79
protected final String load(String uri) throws Exception {
810
return createWebClient()
911
.getPage(url.toURI().resolve(uri).toURL())

html/src/test/java/org/kohsuke/stapler/html/IncludedFromTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ public final class IncludedFromTest extends HtmlTestCase {
88
public void testIncludedFromJelly() throws Exception {
99
assertThat(
1010
load("top"),
11-
is("A prologue. <div> Center text includes a <span>special value</span>. </div> An epilogue."));
11+
is(
12+
"A prologue. <div> Center text includes a <span>special &lt;cool&gt; value</span>. </div> An epilogue."));
1213
}
1314

1415
@HtmlView("center")
1516
public Center getCenter() {
16-
return new Center("special value");
17+
return new Center("special <cool> value");
1718
}
1819

1920
public record Center(String value) {}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.kohsuke.stapler.html;
2+
3+
import static org.hamcrest.MatcherAssert.assertThat;
4+
import static org.hamcrest.Matchers.is;
5+
6+
public final class IncludesTest extends HtmlTestCase {
7+
8+
public void testIncludes() throws Exception {
9+
assertThat(
10+
load("top"),
11+
is(
12+
"<div> A prologue. <span style='color: red'> There are <span>23</span> items. </span> An epilogue. </div>"));
13+
}
14+
15+
@HtmlView("top")
16+
public Top getTop() {
17+
return new Top(new Nested());
18+
}
19+
20+
public record Top(@HtmlInclude("center") Nested nested) {}
21+
22+
public static final class Nested {
23+
24+
@HtmlView("center")
25+
public Center getCenter() {
26+
return new Center("23");
27+
}
28+
29+
public record Center(String count) {}
30+
}
31+
}

html/src/test/java/org/kohsuke/stapler/html/StructureTest.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ public void testStandalone() throws Exception {
2323
load("standalone"),
2424
allOf(
2525
containsString("<title>Something #123</title>"),
26-
containsString(
27-
"<p>All items:</p> <table style='border: 1px solid black'> <tr> <td>k1</td> <td>v1</td> </tr><tr> <td>k2</td> <td>v2 &amp;c.</td> </tr></table>"),
26+
containsString("<p>All items: <table style='border: 1px solid black'> "
27+
+ "<tr><td>Initial name</td><td>Initial value</td></tr> "
28+
+ "<tr> <td>k1</td> <td>v1</td> </tr><tr> <td>k2</td> <td>v2 &amp;c.</td> </tr> "
29+
+ "<tr><td>Penultimate name</td><td>Penultimate value</td></tr> "
30+
+ "<tr><td>Final name</td><td>Final value</td></tr> "
31+
+ "</table>"),
2832
not(containsString("Error in")),
2933
not(containsString("There are"))));
3034
status = new Status("Something #456", new Status.Warning("rotor"), true, null);
@@ -33,8 +37,8 @@ public void testStandalone() throws Exception {
3337
allOf(
3438
containsString("<title>Something #456</title>"),
3539
not(containsString("All items:")),
36-
containsString("<p style='color: red'>Error in <code>rotor</code>.</p>"),
37-
containsString("<p>There are <em>no</em> items.</p>")));
40+
containsString("<p style='color: red'>Error in <code>rotor</code>."),
41+
containsString("<p>There are <em>no</em> items.")));
3842
}
3943

4044
@HtmlView("standalone")
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<span style="color: red">
3+
There are <span id="st.count">1234</span> items.
4+
</span>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<div>
3+
A prologue.
4+
<span id="st.nested">sample interior text</span>
5+
An epilogue.
6+
</div>

0 commit comments

Comments
 (0)