tipsy / j2html

Java to HTML generator. Enjoy typesafe HTML generation.

Home Page:https://j2html.com/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Improvement: invisible parent tag

devxzero opened this issue · comments

I came across a situation in which I need something like an invisible parent to generate something like this:

<ul>
    <li>First</li>
    <li>5</li>
    <li>6</li>
    <li>7</li>
    <li>Last</li>
</ul>

This structure is often used in generating paginating bars. The part from 5 to 7 is generated.

In similar situations, you would an extra div:

ul().with(
    li("First"),
    div().with(
        numbers.stream().map(number ->
            li(number.toString())
        ).collect(Collectors.toList())
    ),
    li("Last")
)

But a div directly inside a UL-element is unwanted.

Another option (without creating new classes):

ul().with(
    li("First"),
    unsafeHtml(
        numbers.stream().map(number ->
            li(number.toString()).render()
        ).collect(Collectors.joining(""))
    ),
    li("Last")
)

But the use of unsafeHtml(), render() and joining("") doesn't feel very nice.

Another option would to to create something like an invisible parent tag, a tag that doesn't show its own element, only its children:

ul().with(
    li("First"),
    group(
        numbers.stream().map(number ->
            li(number.toString())
        ).collect(Collectors.toList())
    ),
    li("Last")
)

This can further be improved with a custom java.util.stream.Collector that internally used the group(), so that group() doesn't have to be exposed:

ul().with(
    li("First"),
    numbers.stream().map(number ->
        li(number.toString())
    ).collect(domContentCollector()),
    li("Last")
)

This last one looks very nice imo, with the domContentCollector().

But solution does require Java 1.8 instead of 1.7.

Code: devxzero@bf7e3ac
(Not fully sure whether DomContentCollector is implemented completely correct.)

commented

Nice work! I was playing around a little, and I really think the stream API is too verbose. I created an alternative suggestion:

li("First"),
each(numbers, number -> li(number.toString())),
li("Last")

Made possible by a helper like this:

DomContent each(Collection<?> collection, Function<Object, DomContent> mapper) {
    return collection.stream().map(mapper).collect(domContentCollect());
}

But it won't really work if you need to do something like employee.getName().
Feels like it should be possible, but I'm still pretty new to functions/lambdas/generics/wildcards.

commented
public static <T> DomContent each(Collection<T> collection, Function<? super T, DomContent> mapper) {
    return unsafeHtml(collection.stream().map(mapper.andThen(DomContent::render)).collect(Collectors.joining()));
}

@jtdev This one is general, and eliminates the need for all the other code/classes. What do you think? Any weaknesses?

@snaketl feel free to give feedback too :)

commented
public class TagCreator {
    //Helper methods
    public static <T> DomContent each(Collection<T> collection, Function<? super T, DomContent> mapper) {
        return unsafeHtml(collection.stream().map(mapper.andThen(DomContent::render)).collect(Collectors.joining()));
    }
    ...
}

private class HelperTest {
    String testLambda() { return "It works!"; }
}
@Test
public void testHelpers() throws Exception {
    List<HelperTest> helperTests = Arrays.asList(new HelperTest(), new HelperTest());
    assertEquals(ul().with(
            li("Start"),
            each(helperTests, h -> li(h.testLambda())),
            li("End")
    ).render(), "<ul><li>Start</li><li>It works!</li><li>It works!</li><li>End</li></ul>");
}

In the beginning, I thought that the stream() and domContentCollector() approach would be more flexible, because you can do everything you can do with streams. But most of the time, you would probably only need a mapping function, so then each() would be fine.

There may be cases where you want to perform stream operations on the list (the first parameter), without the verbosity to create a new local variable. For example when filtering:

Example with domContentCollector():

ul().with(
    li("First"),
    numbers.stream().filter(n -> n % 2 == 0).map(number ->
        li(number.toString())
    ).collect(domContentCollector()),
    li("Last")
)

Example with each():

ul().with(
    li("First"),
    each(numers.stream().filter(n -> n % 2 == 0).collect(Collectors.toList()), number ->
        li(number.toString())
    ),
    li("Last")
)

The each() approach does require two internal iterations on the list, although probably not a problem performance wise. And may be a bit less readable due to the long line.

An extension to this approach would be to overload the each() method with a Stream as first parameter. Then would be:

ul().with(
    li("First"),
    each(numers.stream().filter(n -> n % 2 == 0), number ->
        li(number.toString())
    ),
    li("Last")
)

Which is shorter, more readable and only performs one iteration.

So then we would have two versions/overloads:

public static <T> DomContent each(Collection<T> collection, Function<? super T, DomContent> mapper);
public static <T> DomContent each(Stream<T> stream, Function<? super T, DomContent> mapper);

Introducing each() does make we wonder whether there are alternatives to condWith(), with something like an if() method. (Though "if" is ofcourse a reserverd keyword.)
I'll create a separate issue for this.

commented

An alternative to overloading each() would be to introduce filter()*. We'd be moving even further away from the standard Java-API, but it does look very clean (if a little backwards):

ul().with(
    li("First"),
    each(filter(numbers, n -> n % 2 == 0), number ->
            li(number.toString())
    ),
    li("Last")
)

Introducing each() does make we wonder whether there are alternatives to condWith(), with something like an if() method. (Though "if" is ofcourse a reserverd keyword.)

I considered using given(), but I thought condWith() was more consistent with the rest of the API.

* filter:
public static <T> List<T> filter(Collection<T> collection, Predicate<? super T> filter) {
    return collection.stream().filter(filter).collect(Collectors.toList());
}

Well, here you are going to re-implement the standard Java API and even "if" operator.

I'd suggest using standard, old-fashioned programming with variables, instead of 100% nesting:

Collecton<> indexedItems = numbers.stream().map(number ->
   li(number.toString())
);

ul().with(
    li("First"),
    indexedItems,
    li("Last")
)

I looks much more understandable to my taste.

Of course here we mix DomContent and List - I'm not sure, how to make it efficient.
Probably, .collect(domContentCollector()) is the way to go.

BTW, guys behind Kotlin language managed to achieve quite impressive results with their type-safe black magic:
https://kotlinlang.org/docs/reference/type-safe-builders.html

@kosiakk
Using variables breaks the readability of the html and fluent api imo.

Mixing DomContent with Lists gives a loss of type safety.

And the stream() with .collect(domContentCollector()) approach is a bit verbose, although flexible.
each(collection, mapper) and filter() are a nice short solutions although not sure if they fit all use cases.
each(stream, mapper) would fit more use cases, although slightly more verbose.

About if, I created an issue for it here: #21
if is maybe even more difficult than each in terms of discovering a nice api.

Btw, speaking about Kotlin, Scala also has a HTML builder: Scalatags: http://www.lihaoyi.com/scalatags/
In Clojure (also a JVM language), they have Hiccup: https://github.com/weavejester/hiccup

commented

@kosiakk @jtdev
The each(filter()) combo isn't perfect, but I prefer it by a mile to declaring variables, and mixing DomContent and List is out of the question. I already merged this to master, so I'll close the issue. Thanks to both of you for your input :)