Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTMX integration #41

Open
thekid opened this issue Oct 29, 2023 · 10 comments
Open

HTMX integration #41

thekid opened this issue Oct 29, 2023 · 10 comments

Comments

@thekid
Copy link
Member

thekid commented Oct 29, 2023

Motivation

HTMX is growing in popularity, and is a perfect fit for making server-rendered apps like those created with this library feel "reactive".

Interest over time
Source: https://trends.google.de/trends/explore?q=htmx&date=today%205-y#TIMESERIES

Given that, this issue explores what is necessary to integrate it well.

The basics

To use this library, simply add a script tag:

<script src="https://unpkg.com/htmx.org@1.9.6/dist/htmx.min.js"></script>

Note: You should be bundling your dependencies when going into production - see below

Now, we can make use of the hx-* attributes:

<button hx-post="/clicked" hx-swap="outerHTML">
  Click Me
</button>
use web\Application;
use web\frontend\{Frontend, Handlebars, Get, Post, View};

class Reactive extends Application {

  public function routes() {
    $impl= new class() {
      #[Get]
      public function index() {
        return View::named('reactive');
      }

      #[Post('/clicked')]
      public function clicked() {
        return View::named('clicked');
      }
    };
    return new Frontend($impl, new Handlebars('.'));
  }
}

Handling errors

When an HTMX request cannot reach the server (flaky wifi, offline, ...), there is no immediate feedback to the user except for in the browser console. This can be solved by adding an event listener as follows:

document.body.addEventListener('htmx:sendError', e => {
  // For example, show a message box
});

For requests causing internal server errors, we should also handle htmx:responseErrors; a complete implementation would include handling htmx:timeouts, e.g. by asking the user whether to continue.

See https://htmx.org/events/#htmx:sendError, https://htmx.org/events/#htmx:responseError and https://htmx.org/events/#htmx:timeout

CSRF token

This library includes automatic CSRF token handling. To include the CSRF token in HTMX requests, the following can be done:

{{#if request.values.token}}
  document.body.addEventListener('htmx:configRequest', e => {
    e.detail.headers['X-Csrf-Token'] = '{{request.values.token}}';
  });
{{/if}}

Note: Support was added in PR #40

This will save adding the CSRF token manually to each element, e.g. <span hx-post="/clicked" #{{if request.values.token}}hx-vals='{"token":"{{request.values.token}}"}'{{/if}}>...</span>.

Authentication

When an HTMX request triggers re-authentication, e.g. because the session expired, it might cause redirects to the HTMX target (/clicked in the above example) when using protocols such as OAuth. This is not desireable, we instead want to show an error message to the user that his or her session has expired. Solving this consists of raising an error in the backend:

$htmx= new class($flow) extends Flow {
  public function __construct(private Flow $delegate) { }

  /** Delegate refreshing */
  public function refresh(array $claims) {
    return $this->delegate->refresh($claims);
  }

  /**
   * If we need to (re-)authenticate HTMX requests, send back an error
   * instead of redirecting.
   *
   * @see  https://htmx.org/reference/#headers
   */
  public function authenticate($request, $response, $session) {
    if ('true' === $request->header('Hx-Request')) {
      $response->answer(401, 'Authentication expired');
      return null;
    }

    return $this->delegate->authenticate($request, $response, $session);
  }
};

...and handling that in the frontend:

document.body.addEventListener('htmx:responseError', e => {
  if (401 === e.detail.xhr.status) {
    if (confirm(e.detail.error + '. Do you want to re-authenticate?')) {
      window.location.reload();
      return;
    }
  }
  // Handle other errors
});

We could think about using a dedicated response codes to disambiguate, e.g. https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/421 ("Misdirected Request [...] indicates that the request was directed to a server that is not able to produce a response")

@thekid
Copy link
Member Author

thekid commented Oct 29, 2023

As an alternative to using configRequest to add the CSRF header, we can use https://htmx.org/attributes/hx-headers/ on the body element:

<body {{#with request.values.token}}hx-headers='{"X-CSRF-Token": "{{.}}"}'{{/with}}>
  <!-- ... -->
</body>

See https://www.mattlayman.com/blog/2021/how-to-htmx-django/ and https://django-htmx.readthedocs.io/en/latest/tips.html

@thekid
Copy link
Member Author

thekid commented Oct 29, 2023

See also https://laravel.io/articles/getting-started-with-htmx-in-laravel-an-overview and https://github.com/mauricius/laravel-htmx.


The article https://dev.to/turculaurentiu91/laravel-htmx--g0n features progressive enhanchement techniques:

Ensure that HTMX requests to the edit chirp endpoint receive only the edit chirp component, while other requests receive the whole page, aligning with the "progressive enhancement" pattern.

public function edit(Request $request, Chirp $chirp): Response  
{  
    $this->authorize('update', $chirp);  

    if ($request->header('HX-Request')) {  
        return response()->view('components.chirps.edit', [  
            'chirp' => $chirp,  
        ]);  
    }  

    return response()->view('chirps.edit', [  
        'chirp' => $chirp,  
    ]);  
}

See https://github.com/turculaurentiu91/chirper-htmx

@thekid
Copy link
Member Author

thekid commented Oct 29, 2023

To use the bundling mechansim, put the following inside the file package.json:

{
  "dependencies": {
    "htmx.org": "^1.9"
  },
  "bundles": {
    "vendor": {"htmx.org": ["dist/htmx.min.js"]}
  }
}

...and then run xp bundle src/main/webapp/static to create vendor.js (and compressed versions of it) in the given directory. These assets can then be delivered via the web.frontend.AssetsFrom handler.

@thekid
Copy link
Member Author

thekid commented Oct 29, 2023

:shipit: CSRF Token header was released in https://github.com/xp-forge/frontend/releases/tag/v5.3.0

@thekid
Copy link
Member Author

thekid commented Oct 29, 2023

The article https://dev.to/turculaurentiu91/laravel-htmx--g0n features progressive enhanchement techniques

To realize this with the frontend library, we specify a full form including the hx-* attributes as follows:

<form name="post" hx-post="/messages" action="/messages" method="POST">
  <input type="hidden" name="token" value="{{request.values.token}}">
  <input type="text" name="message" placeholder="Your message...">
  <button type="submit">Post</button>
</form>

...and then declare the handler as follows:

#[Handler('/messages')]
class Messages {
  private $posts= [];

  #[Get]
  public function list() {
    return View::named('reactive')->with(['posts' => $this->posts]);
  }

  #[Post]
  public function create(#[Value] $user, #[Param] $message, #[Header('Hx-Request')] $hx= false) {
    $this->posts[]= ['message' => $message, 'author' => $user['name'], 'date' => date('Y-m-d H:i')];

    if ($hx) {
      return View::named('reactive')->fragment('list')->with(['posts' => $this->posts]);
    } else {
      return View::redirect('/');
    }
  }
}

Should JavaScript be disabled or error on loading, the user could still use the page, with the user being redirected back after their POST request has invoked the create handler. To make this work for HTTP method such as PUT, PATCH and DELETE, see #42


The above could be generalized by the following:

Matching up target and fragment:

<div class="segments" hx-target="#list">  <!-- The target here (w/o the leading "#")... -->
  <div id="list" class="segments">        <!-- ...equals this ID                        -->
    {{#*fragment "list"}}                 <!-- ...and this fragment's name               -->
      {{#each posts}}
        ...
      {{/each}}
    {{/fragment}}
  </div>

  <form name="post" hx-post="/messages">
    <!-- Create post form -->
  </form>
</div>

Creating this small integration class:

class Htmx {
  public function rerender($req, $view) {
    if ('true' === $req->header('Hx-Request')) {
      return $view()->fragment($req->header('Hx-Fragment') ?? $req->header('Hx-Target'));
    } else {
      return View::redirect('/');
    }
  }
}

Note: If we cannot use an ID, we can pass the header with the fragment's name via hx-headers='{"Hx-Fragment":"list"}'.

Using this Htmx class inside the Messages handler as follows:

 #[Handler('/messages')]
 class Messages {
   private $posts= [];
+  private $htmx;

+  public function __construct() {
+    $this->htmx= new Htmx();
+  }

   #[Get]
   public function list() {
     return View::named('reactive')->with(['posts' => $this->posts]);
   }

   #[Post]
-  public function create(#[Value] $user, #[Param] $message, #[Header('Hx-Request')] $hx= false) {
+  public function create(#[Value] $user, #[Param] $message, #[Request] $request) {
     $this->posts[]= ['message' => $message, 'author' => $user['name'], 'date' => date('Y-m-d H:i')];
-
-    if ($hx) {
-      return View::named('reactive')->fragment('list')->with(['posts' => $this->posts]);
-    } else {
-      return View::redirect('/');
-    }
+    return $this->htmx->rerender($req, $this->list(...));
   }
 }

@thekid
Copy link
Member Author

thekid commented Nov 18, 2023

A good example usecase is shown in https://www.youtube.com/watch?v=akd7u69k27k - implemented #44 to short-hand setting template and fragment, and #45 to support the DELETE usecase.


In the PHP implementation, I chose to use hx-swap="delete" (see here) instead of triggering an event when deleting.

Before

This still has some minimal JS involved, and adds some HTMX-specific code to the PHP part:

<button hx-delete="/todos/{{id}}" hx-on:delete-todo="this.closest('tr').remove()">Delete</button>
#[Delete('/todos/{id}')]
public function remove($id) {
  $this->conn->delete('from todo where id = %d', $id);

  return View::empty()->status(204)->header('HX-Trigger', 'delete-todo');
}

This could be something like return $this->htmx->trigger('delete-todo');, see above

After

This makes it necessary to a non-204 status code - otherwise, HTMX will not do anything.

<button hx-delete="/todos/{{id}}" hx-target="closest tr" hx-swap="delete">Delete</button>
#[Delete('/todos/{id}')]
public function remove($id) {
  $this->conn->delete('from todo where id = %d', $id);

  return View::empty()->status(202);
}

This could also just use return View::empty(); but using "Accepted" feels a bit more "resty" 😊

@thekid
Copy link
Member Author

thekid commented Dec 13, 2023

As an alternative to using configRequest to add the CSRF header, we can use https://htmx.org/attributes/hx-headers/ on the body element:

This does not work out of the box with AWS lambda & CloudFront - most probably needing an extra header configuration!

@thekid
Copy link
Member Author

thekid commented Dec 25, 2023

How this is done with other languages & frameworks: https://htmx.org/server-examples/#php - once https://github.com/thekid/crews reaches an MVP state, we could add it there!

@thekid
Copy link
Member Author

thekid commented Jan 28, 2024

The authentication part is now taken care of by https://github.com/xp-forge/htmx

@thekid
Copy link
Member Author

thekid commented Feb 4, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant