Skip to content

Commit

Permalink
HTMx showcase
Browse files Browse the repository at this point in the history
  • Loading branch information
FroMage committed Sep 11, 2023
1 parent 43b13a9 commit e1e8a90
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 13 deletions.
109 changes: 109 additions & 0 deletions src/main/java/rest/HxControllerWithUser.java
@@ -0,0 +1,109 @@
package rest;

import java.util.Arrays;
import java.util.Objects;

import io.quarkiverse.renarde.security.ControllerWithUser;
import io.quarkiverse.renarde.security.RenardeUser;
import io.quarkus.qute.Qute;
import io.quarkus.qute.TemplateInstance;
import io.vertx.core.http.HttpServerResponse;
import jakarta.inject.Inject;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;

public abstract class HxControllerWithUser<T extends RenardeUser> extends ControllerWithUser<T> {
public static final String HX_REQUEST_HEADER = "HX-Request";

public enum HxResponseHeader {
TRIGGER("HX-Trigger"), // Allows you to trigger client side events
REDIRECT("HX-Redirect"), // Can be used to do a client-side redirect to a new location
LOCATION("HX-Location"), // Allows you to do a client-side redirect that does not do a full page reload
REFRESH("HX-Refresh"), // If set to “true” the client side will do a a full refresh of the page
PUSH_URL("HX-Push-Url"), // Replaces the current URL in the location bar
HX_RESWAP("HX-Reswap"), // Allows you to specify how the response will be swapped. See hx-swap for possible values
HX_RETARGET("HX-Retarget"), // A CSS selector that updates the target of the content update to a different element on the page
TRIGGER_AFTER_SWAP("HX-Trigger-After-Swap"), // allows you to trigger client side events
TRIGGER_AFTER_SETTLE("HX-Trigger-After-Settle"); // allows you to trigger client side events

private final String key;

HxResponseHeader(String key) {
this.key = key;
}

public String key() {
return key;
}
}

@Inject
protected HttpHeaders httpHeaders;

@Inject
protected HttpServerResponse response;

/**
* This Qute helper make it easy to achieve htmx "Out of Band" swap by choosing which templates to return (refresh).
* <br />
* {@see <a href="https://htmx.org/attributes/hx-swap-oob/">Doc for htmx "hx-swap-oob"</a>}
*
* <br />
*
* @param templates the list of template instances to concatenate
* @return the concatenated templates instances
*/
public static TemplateInstance concatTemplates(TemplateInstance... templates) {
return Qute.fmt("{#each elements}{it.raw}{/each}")
.cache()
.data("elements", Arrays.stream(templates).map(TemplateInstance::createUni))
.instance();
}

/**
* Check if this request has the htmx flag (header or flash data)
*/
protected boolean isHxRequest() {
final boolean hxRequest = Objects.equals(flash.get(HX_REQUEST_HEADER), true);
if (hxRequest) {
return true;
}
return Objects.equals(httpHeaders.getHeaderString(HX_REQUEST_HEADER), "true");
}

/**
* Helper to define htmx response headers.
*
* @param hxHeader the {@link HxResponseHeader} to define
* @param value the value for this header
*/
protected void hx(HxResponseHeader hxHeader, String value) {
response.headers().set(hxHeader.key(), value);
}

/**
* Make sure only htmx requests are calling this method.
*/
protected void onlyHxRequest() {
if (!isHxRequest()) {
throw new WebApplicationException(
Response.status(Response.Status.BAD_REQUEST).entity("Only Hx request are allowed on this method").build());
}
}

/**
* Keep the htmx flag for the redirect request.
* This is automatic.
*/
protected void flashHxRequest() {
flash(HX_REQUEST_HEADER, isHxRequest());
}

@Override
protected void beforeRedirect() {
flashHxRequest();
super.beforeRedirect();
}

}
56 changes: 43 additions & 13 deletions src/main/java/rest/Todos.java
Expand Up @@ -7,7 +7,7 @@
import org.jboss.resteasy.reactive.RestPath;

import io.quarkiverse.renarde.pdf.Pdf;
import io.quarkiverse.renarde.security.ControllerWithUser;
import io.quarkus.logging.Log;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.security.Authenticated;
Expand All @@ -20,11 +20,15 @@

@Blocking
@Authenticated
public class Todos extends ControllerWithUser<User> {
public class Todos extends HxControllerWithUser<User> {

@CheckedTemplate
static class Templates {
public static native TemplateInstance index(List<Todo> todos);
public static native TemplateInstance htmx(List<Todo> todos, String message);
public static native TemplateInstance htmx$row(Todo todo);
public static native TemplateInstance htmx$message(String message);
public static native TemplateInstance htmx$error(String message);
public static native TemplateInstance pdf(List<Todo> todos);
}

Expand All @@ -33,46 +37,72 @@ public TemplateInstance index() {
return Templates.index(todos);
}

public TemplateInstance htmx() {
List<Todo> todos = Todo.findByOwner(getUser());
return Templates.htmx(todos, null);
}

@Produces(Pdf.APPLICATION_PDF)
public TemplateInstance pdf() {
List<Todo> todos = Todo.findByOwner(getUser());
return Templates.pdf(todos);
}

@POST
public void delete(@RestPath Long id) {
public TemplateInstance delete(@RestPath Long id) {
Todo todo = Todo.findById(id);
notFoundIfNull(todo);
if(todo.owner != getUser())
notFound();
todo.delete();
flash("message", i18n.formatMessage("todos.message.deleted", todo.task));
index();
String message = i18n.formatMessage("todos.message.deleted", todo.task);
if (isHxRequest()) {
// HTMX bug: https://github.com/bigskysoftware/htmx/issues/1043
// return concatTemplates(Templates.htmx$message(message), Templates.htmx$row(todo));
return Templates.htmx$row(todo);
} else {
return index();
}
}

@POST
public void done(@RestPath Long id) {
public TemplateInstance done(@RestPath Long id) {
Todo todo = Todo.findById(id);
notFoundIfNull(todo);
if(todo.owner != getUser())
notFound();
todo.done = !todo.done;
if(todo.done)
todo.doneDate = new Date();
flash("message", i18n.formatMessage("todos.message.updated", todo.task));
index();
String message = i18n.formatMessage("todos.message.updated", todo.task);
if (isHxRequest()) {
// HTMX bug: https://github.com/bigskysoftware/htmx/issues/1043
// return concatTemplates(Templates.htmx$message(message), Templates.htmx$row(todo));
return Templates.htmx$row(todo);
} else {
flash("message", message);
return index();
}
}

@POST
public void add(@NotBlank @RestForm String task) {
if(validationFailed()) {
index();
public TemplateInstance add(@NotBlank @RestForm String task) {
if (isHxRequest() && validation.hasErrors()) {
return Templates.htmx$error("Cannot be empty: "+task);
} else if(validationFailed()) {
index();
}
Todo todo = new Todo();
todo.task = task;
todo.owner = getUser();
todo.persist();
flash("message", i18n.formatMessage("todos.message.updated", todo.task));
index();
String message = i18n.formatMessage("todos.message.added", todo.task);
if (isHxRequest()) {
// HTMX bug: https://github.com/bigskysoftware/htmx/issues/1043
// return concatTemplates(Templates.htmx$message(message), Templates.htmx$row(todo));
return Templates.htmx$row(todo);
} else {
return index();
}
}
}
66 changes: 66 additions & 0 deletions src/main/resources/templates/Todos/htmx.html
@@ -0,0 +1,66 @@
{#include main.html }
{#title}Todos Htmx{/title}

{#moreScripts}
<script src="https://unpkg.com/htmx.org@1.9.5" integrity="sha384-xcuj3WpfgjlKF+FXhSQFQ0ZNr39ln+hwjN3npfM9VBnUskLolQAcN80McRIVOPuO" crossorigin="anonymous"></script>
{/moreScripts}

<div id="message">
{#fragment id="message" rendered=false}
<div id="message" hx-swap-oob="true" class="alert alert-success">{message}</div>
{/fragment}
{#fragment id="error" rendered=false}
<div id="message" hx-swap-oob="true" class="alert alert-danger">{message}</div>
{/fragment}
</div>

<table class="table">
<thead class="table-dark">
<tr>
<th scope="col">#</th>
<th scope="col">{m:todos.index.tasks}</th>
<th scope="col">{m:todos.index.action} <a href="{uri:Todos.pdf}" title="{m:todos.index.pdf}" class="float-end text-reset"><i class="bi bi-filetype-pdf"></i></a></th>
</tr>
</thead>
<tbody id="todos">
{#for todo in todos}
{#fragment id="row"}
<tr {#if todo.done}class="table-secondary"{/if} id="row-{todo.id}">
<th scope="row">{todo.id}</th>
<td>
{#if todo.done}
<del>{todo.task}</del> (done {todo.doneDate.since})
{#else}
{todo.task}
{/if}
</td>
<td>
{#if todo.done}
<button type="submit" class="btn btn-warning" title="{m:todos.index.done}"
hx-post="{uri:Todos.done(todo.id)}" hx-target="#row-{todo.id}" hx-swap="outerHTML"
hx-vals='{"{inject:csrf.parameterName}": "{inject:csrf.token}"}'><i class="bi-arrow-counterclockwise"></i></button>
{#else}
<button type="submit" class="btn btn-success" title="{m:todos.index.undone}"
hx-post="{uri:Todos.done(todo.id)}" hx-target="#row-{todo.id}" hx-swap="outerHTML"
hx-vals='{"{inject:csrf.parameterName}": "{inject:csrf.token}"}'><i class="bi-check"></i></button>
{/if}
<button type="submit" class="btn btn-danger" title="{m:todos.index.delete}"
hx-post="{uri:Todos.delete(todo.id)}" hx-target="#row-{todo.id}" hx-swap="delete"
hx-vals='{"{inject:csrf.parameterName}": "{inject:csrf.token}"}'><i class="bi-trash"></i></button>
</td>
</tr>
{/fragment}
{/for}
<tr id="new">
<th scope="row">{m:todos.index.new}</th>
<td>
<input hx-post="{uri:Todos.add()}" name="task" placeholder="{m:todos.index.placeholder}" autofocus
hx-target="#new" hx-swap="beforebegin"
hx-vals='{"{inject:csrf.parameterName}": "{inject:csrf.token}"}'/>
</td>
<td></td>
</tr>
</tbody>
</table>

{/include}
1 change: 1 addition & 0 deletions src/main/resources/templates/main.html
Expand Up @@ -28,6 +28,7 @@
<li class="nav-item"><a class="nav-link" aria-current="page" href="{uri:Login.login()}">{m:main.login}</a></li>
{#else}
<li class="nav-item"><a class="nav-link" aria-current="page" href="{uri:Todos.index()}">{m:main.todos}</a></li>
<li class="nav-item"><a class="nav-link" aria-current="page" href="{uri:Todos.htmx()}">{m:main.todos} (htmx)</a></li>
{#if inject:user.isAdmin}
<li class="nav-item"><a class="nav-link" aria-current="page" href="/_renarde/backoffice/index"><i class="bi bi-database"></i>{m:main.backoffice}</a></li>
{/if}
Expand Down

0 comments on commit e1e8a90

Please sign in to comment.