wimdeblauwe / htmx-spring-boot

Spring Boot and Thymeleaf helpers for working with htmx
Apache License 2.0
423 stars 41 forks source link

Add HxPush annotation #105

Closed tschuehly closed 2 months ago

tschuehly commented 2 months ago

We have a working HxPush annotation using an Interceptor, should I create a PR?

@Repeatable(HxPushs.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@HxRequest
public @interface HxPush {

  String url() default "";

  String urlPrefix() default "";

  @Retention(RetentionPolicy.RUNTIME)
  @Target(ElementType.METHOD)
  @interface HxPushs {

    HxPush[] value();
  }
}
public class HtmxPushUrlInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(
      @NotNull HttpServletRequest request,
      @NotNull HttpServletResponse response,
      @NotNull Object handler) {
    if (handler instanceof HandlerMethod handlerMethod) {
      var hxPushUrls =
          AnnotatedElementUtils.findMergedRepeatableAnnotations(
              handlerMethod.getMethod(), HxPush.class, HxPush.HxPushs.class);
      if (CollectionUtils.isNotEmpty(hxPushUrls)) {
        var refererPath =
            String.valueOf(request.getHeader("referer"))
                .replace(String.valueOf(request.getHeader("origin")), "");
        var matchingPrefix =
            hxPushUrls.stream()
                .filter(
                    url ->
                        Strings.isNotEmpty(url.urlPrefix())
                            && refererPath.startsWith(url.urlPrefix()))
                .findFirst();
        var withoutPrefix =
            hxPushUrls.stream().filter(url -> Strings.isEmpty(url.urlPrefix()))
                .findFirst();
        if (matchingPrefix.isPresent()) {
          addHeader(request, response, matchingPrefix.get());
          return true;
        }
        withoutPrefix.ifPresent((prefix) -> addHeader(request, response, prefix));
        return true;
      }
    }
    return true;
  }

  private void addHeader(
      @NotNull HttpServletRequest request,
      @NotNull HttpServletResponse response,
      HxPush hxPushUrl) {
    if (Strings.isBlank(hxPushUrl.url())) {
      if (request.getQueryString() != null) {
        response.addHeader("HX-Push-Url",
            request.getServletPath() + "?" + request.getQueryString());
        return;
      }
      response.addHeader("HX-Push-Url", request.getServletPath());
    } else {
      response.addHeader("HX-Push-Url", hxPushUrl.url());
    }
  }
}
wimdeblauwe commented 2 months ago

Can you give some examples of where you would use this?

tschuehly commented 2 months ago

Any time I make a HX Request with dynamic properties and I want to push the URL into the browser history: image

wimdeblauwe commented 2 months ago

So it is an annotation alternative to using the HtmxResponse return type if I understand it correctly. You can already do this now:

@HxRequest
@GetMapping(path=TasksWebPath.OVERVIEW_SCOPE)
public HtmxResponse taskOverview(@PathVariable("scope") String scope) {
  ViewContext viewContext = taskOverview.renderTaskOverview(TaskScope.validate(scope));
   return HtmxResponse.builder.addTemplate(viewContext).pushUrl("/the/url/here").build();
}

Not as convenient, and we require the URL in the pushUrl method. I think I like this :-)

checketts commented 2 months ago

I also like it. It will also need to support setting a custom url:

@HxRequest
@HxPush //Implies the value of the current request mapping
@GetMapping(path=TasksWebPath.OVERVIEW_SCOPE)
public HtmxResponse taskOverview(@PathVariable("scope") String scope) {
  ViewContext viewContext = taskOverview.renderTaskOverview(TaskScope.validate(scope));
   return HtmxResponse.builder.addTemplate(viewContext).build();
}

and

@HxRequest
@HxPush("/alternate/path")
@GetMapping(path=TasksWebPath.OVERVIEW_SCOPE)
public HtmxResponse taskOverview(@PathVariable("scope") String scope) {
  ViewContext viewContext = taskOverview.renderTaskOverview(TaskScope.validate(scope));
   return HtmxResponse.builder.addTemplate(viewContext).build();
}
tschuehly commented 2 months ago

Yeah most of my improvement ideas do not consider the HtmxResponse builder as I use ViewContexts instead.

xhaggi commented 2 months ago

Just a reminder: I have already done the work for this and all other missing response header annotations in PR https://github.com/wimdeblauwe/htmx-spring-boot/pull/67 (Commit for @HxPush https://github.com/wimdeblauwe/htmx-spring-boot/pull/67/commits/c2020f56b13d4120bba2ece066e473e28fd9754c).

If you want to push the current request URL to the browser history just set pushUrl to true in HtmxResonse.builder(). BTW the docs for HX-Push-Url are not clear enough about that, but all possible values of hx-push-url also apply to the header.

@HxRequest
@GetMapping(path=TasksWebPath.OVERVIEW_SCOPE)
public HtmxResponse taskOverview(@PathVariable("scope") String scope) {
   return HtmxResponse.builder()
    .view(taskOverview.renderTaskOverview(TaskScope.validate(scope));)
    .pushUrl("true") // or .pushUrl("/alternative/URL")
    .build();
}

BTW What I could imagine to simplify the use of the builder a bit would be a convenience method e.g. pushUrl() without a parameter. We already had that for pushUrl("false") via [preventHistoryUpdate()](https://javadoc.io/static/io.github.wimdeblauwe/htmx-spring-boot/3.2.0/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.Builder.html#preventHistoryUpdate() )

wimdeblauwe commented 2 months ago

Right, thanks for reminding me about that @xhaggi. I don't have much time to work on this, but I would be happy to review a PR if somebody wants to take this up.

xhaggi commented 2 months ago

@tschuehly could you please give us some more insight into your code? Why do you need a prefix and why do you add the request.getServletPath() in case @HxPush is blank?

I would recommend not moving too much htmx-specific code to the server side. If you want to push the current URL that htmx has requested to the browser history, simply add the attribute hx-push-url="true" to the HTML element in your template code.

tschuehly commented 2 months ago

We needed a prefix when we had multiple @hxpush annotations but I would probably remove it. For us @Hxpush most often has no path as the paths are dynamic. The templates are created on the server so I see no difference between doing it on the template or the Endpoint.

I prefer doing it on the Endpoints as I don't know at component creation time if I need to push the URL or not.

tschuehly commented 2 months ago

@wimdeblauwe @xhaggi I've ported xhaggis work to the current main and opened a pull request: https://github.com/wimdeblauwe/htmx-spring-boot/pull/117

wimdeblauwe commented 2 months ago

118 has been merged now and @HxPushUrl is part of it.