From ea0bf6854772a5181f734ecf906503444ce8b68a Mon Sep 17 00:00:00 2001 From: Peter Fokkinga Date: Fri, 19 Apr 2024 06:58:02 +0200 Subject: [PATCH] feat: new "morph" and "refresh" action types for turbo-stream, issue #772 (#773) --- .../io/micronaut/views/turbo/TurboStream.java | 97 +++++++++++++++++++ .../views/turbo/TurboStreamAction.java | 12 ++- .../io/micronaut/views/turbo/TurboView.java | 12 +++ .../views/turbo/TurboStreamSpec.groovy | 38 +++++++- 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/views-core/src/main/java/io/micronaut/views/turbo/TurboStream.java b/views-core/src/main/java/io/micronaut/views/turbo/TurboStream.java index 8c50b5957..0aa2663d2 100644 --- a/views-core/src/main/java/io/micronaut/views/turbo/TurboStream.java +++ b/views-core/src/main/java/io/micronaut/views/turbo/TurboStream.java @@ -41,12 +41,16 @@ public final class TurboStream implements Renderable { private static final String MEMBER_ACTION = "action"; private static final String MEMBER_TARGET_DOM_ID = "targetDomId"; private static final String MEMBER_TARGET_CSS_QUERY_SELECTOR = "targetCssQuerySelector"; + private static final String MEMBER_REQUEST_ID = "requestId"; + private static final String MEMBER_CHILDREN_ONLY = "childrenOnly"; private static final String TURBO_TEMPLATE_TAG = "template"; private static final String TURBO_STREAM_TAG = "turbo-stream"; private static final String TURBO_STREAM_CLOSING_TAG = ""; private static final String TURBO_STREAM_ATTRIBUTE_TARGET = "target"; private static final String TURBO_STREAM_ATTRIBUTE_ACTION = "action"; private static final String TURBO_STREAM_ATTRIBUTE_TARGETS = "targets"; + private static final String TURBO_STREAM_ATTRIBUTE_REQUEST_ID = "request-id"; + private static final String TURBO_STREAM_ATTRIBUTE_CHILDREN_ONLY = "children-only"; private static final String CLOSE_TAG = ">"; private static final String OPEN_TAG = "<"; private static final String SPACE = " "; @@ -69,9 +73,26 @@ public final class TurboStream implements Renderable { @Nullable private final String targetCssQuerySelector; + /** + * request-id attribute, only relevant when action=refresh + */ + @Nullable + private final String requestId; + + /** + * Morph only the children of the element designated by the target dom id. + */ + private final boolean childrenOnly; + @Nullable private final Object template; + /** + * + * @deprecated use the constructor that takes also the {@code requestId} and + * {@code childrenOnly} parameters instead + */ + @Deprecated TurboStream(@NonNull TurboStreamAction action, @Nullable String targetDomId, @Nullable String targetCssQuerySelector, @@ -79,6 +100,22 @@ public final class TurboStream implements Renderable { this.action = action; this.targetDomId = targetDomId; this.targetCssQuerySelector = targetCssQuerySelector; + this.requestId = null; + this.childrenOnly = false; + this.template = template; + } + + TurboStream(@NonNull TurboStreamAction action, + @Nullable String targetDomId, + @Nullable String targetCssQuerySelector, + @Nullable String requestId, + boolean childrenOnly, + @Nullable Object template) { + this.action = action; + this.targetDomId = targetDomId; + this.targetCssQuerySelector = targetCssQuerySelector; + this.requestId = requestId; + this.childrenOnly = childrenOnly; this.template = template; } @@ -109,6 +146,22 @@ public Optional getTargetCssQuerySelector() { return Optional.ofNullable(targetCssQuerySelector); } + /** + * + * @return request-id attribute, only relevant when action=refresh + */ + public Optional getRequestId() { + return Optional.ofNullable(requestId); + } + + /** + * + * @return Morph only the children of the element designated by the target dom id. + */ + public boolean getChildrenOnly() { + return childrenOnly; + } + /** * * @return Template. @@ -164,6 +217,8 @@ private String renderTurboStreamOpeningTag() { return OPEN_TAG + TURBO_STREAM_TAG + SPACE + htmlAttribute(TURBO_STREAM_ATTRIBUTE_ACTION, getAction().toString()) + getTargetDomIdHtmlAttribute().orElse("") + getTargetCssQuerySelectorHtmlAttribute().orElse("") + + getRequestIdAttribute().orElse("") + + getChildrenOnlyAttribute().orElse("") + CLOSE_TAG; } @@ -179,6 +234,19 @@ private Optional getTargetCssQuerySelectorHtmlAttribute() { .map(cssSelector -> SPACE + htmlAttribute(TURBO_STREAM_ATTRIBUTE_TARGETS, cssSelector)); } + @NonNull + private Optional getRequestIdAttribute() { + return getRequestId() + .map(requestId -> SPACE + htmlAttribute(TURBO_STREAM_ATTRIBUTE_REQUEST_ID, requestId)); + } + + @NonNull + private Optional getChildrenOnlyAttribute() { + return getChildrenOnly() + ? Optional.of(SPACE + TURBO_STREAM_ATTRIBUTE_CHILDREN_ONLY) + : Optional.empty(); + } + @NonNull private String htmlAttribute(@NonNull String key, @NonNull String value) { return String.join(EQUALS, key, DOUBLE_QUOTE + value + DOUBLE_QUOTE); @@ -208,6 +276,8 @@ public static class Builder { private TurboStreamAction action; private String targetDomId; private String targetCssQuerySelector; + private String requestId; + private boolean childrenOnly; private Object template; private String templateView; private Object templateModel; @@ -273,6 +343,29 @@ public Builder targetCssQuerySelector(@NonNull String targetCssQuerySelector) { return this; } + + /** + * + * @param requestId request-id attribute, only relevant when action=refresh + * @return The Builder + */ + @NonNull + public Builder requestId(@NonNull String requestId) { + this.requestId = requestId; + return this; + } + + /** + * + * @param childrenOnly Morph only the children of the element designated by the target dom id. + * @return The Builder + */ + @NonNull + public Builder childrenOnly(boolean childrenOnly) { + this.childrenOnly = childrenOnly; + return this; + } + /** * Sets the template with a View and Model. * @param view The View name @@ -409,6 +502,8 @@ public TurboStream build() { return new TurboStream(action, targetDomId, targetCssQuerySelector, + requestId, + childrenOnly, template); } @@ -473,6 +568,8 @@ private static Optional of(@NonNull HttpRequest request, route.getValue(TurboView.class, MEMBER_ACTION, TurboStreamAction.class).ifPresent(builder::action); route.stringValue(TurboView.class, MEMBER_TARGET_DOM_ID).ifPresent(builder::targetDomId); route.stringValue(TurboView.class, MEMBER_TARGET_CSS_QUERY_SELECTOR).ifPresent(builder::targetCssQuerySelector); + route.stringValue(TurboView.class, MEMBER_REQUEST_ID).ifPresent(builder::requestId); + route.booleanValue(TurboView.class, MEMBER_CHILDREN_ONLY).ifPresent(builder::childrenOnly); if (!builder.getTargetCssQuerySelector().isPresent() && !builder.getTargetDomId().isPresent()) { diff --git a/views-core/src/main/java/io/micronaut/views/turbo/TurboStreamAction.java b/views-core/src/main/java/io/micronaut/views/turbo/TurboStreamAction.java index dbb499891..2df2fe8d0 100644 --- a/views-core/src/main/java/io/micronaut/views/turbo/TurboStreamAction.java +++ b/views-core/src/main/java/io/micronaut/views/turbo/TurboStreamAction.java @@ -57,7 +57,17 @@ public enum TurboStreamAction { /** * Inserts the content within the template tag after the element designated by the target dom id. */ - AFTER("after"); + AFTER("after"), + + /** + * Replaces the element designated by the target dom id via morph. + */ + MORPH("morph"), + + /** + * Initiates a Page Refresh to render new content with morphing. + */ + REFRESH("refresh"); @NonNull private final String action; diff --git a/views-core/src/main/java/io/micronaut/views/turbo/TurboView.java b/views-core/src/main/java/io/micronaut/views/turbo/TurboView.java index 36b11cdc5..e79b2eb1e 100644 --- a/views-core/src/main/java/io/micronaut/views/turbo/TurboView.java +++ b/views-core/src/main/java/io/micronaut/views/turbo/TurboView.java @@ -55,4 +55,16 @@ * @return Target CSS Query Selector. */ String targetCssQuerySelector() default ""; + + /** + * + * @return request-id attribute, only relevant when action=refresh + */ + String requestId() default ""; + + /** + * + * @return morph only the children of the element designated by the target dom id + */ + boolean childrenOnly() default false; } diff --git a/views-core/src/test/groovy/io/micronaut/views/turbo/TurboStreamSpec.groovy b/views-core/src/test/groovy/io/micronaut/views/turbo/TurboStreamSpec.groovy index 5a0ae8473..d80cd188c 100644 --- a/views-core/src/test/groovy/io/micronaut/views/turbo/TurboStreamSpec.groovy +++ b/views-core/src/test/groovy/io/micronaut/views/turbo/TurboStreamSpec.groovy @@ -155,6 +155,28 @@ class TurboStreamSpec extends Specification { "" == html } + void "verify TurboView annotation can specify requestId"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + String html = client.retrieve(HttpRequest.GET("/turbo/requestId").accept(TurboMediaType.TURBO_STREAM, MediaType.TEXT_HTML, MediaType.APPLICATION_XHTML)) + + then: + "" == html + } + + void "verify TurboView annotation can specify childrenOnly"() { + given: + BlockingHttpClient client = httpClient.toBlocking() + + when: + String html = client.retrieve(HttpRequest.GET("/turbo/childrenOnly").accept(TurboMediaType.TURBO_STREAM, MediaType.TEXT_HTML, MediaType.APPLICATION_XHTML)) + + then: + "" == html + } + void "verify TurboView annotation can specify targetCssQuerySelector"() { given: BlockingHttpClient client = httpClient.toBlocking() @@ -409,7 +431,7 @@ class TurboStreamSpec extends Specification { @TurboView("fragments/message") @Get("/update") String update() { - "Hello World"; + "Hello World" } @Produces(TurboMediaType.TURBO_STREAM) @@ -419,6 +441,20 @@ class TurboStreamSpec extends Specification { "Hello World" } + @Produces(TurboMediaType.TURBO_STREAM) + @TurboView(value = "fragments/message", action=TurboStreamAction.REFRESH, requestId = "abcd-1234") + @Get("/requestId") + String requestId() { + "Hello World" + } + + @Produces(TurboMediaType.TURBO_STREAM) + @TurboView(value = "fragments/message", action=TurboStreamAction.MORPH, childrenOnly = true) + @Get("/childrenOnly") + String childrenOnly() { + "Hello World" + } + @Produces(TurboMediaType.TURBO_STREAM) @TurboView(action = TurboStreamAction.REMOVE) @Get("/del")