xp-forge / frontend

Web frontends
1 stars 1 forks source link

HTMX integration #41

Open thekid opened 1 year ago

thekid commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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

thekid commented 1 year ago

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 https://github.com/xp-forge/frontend/pull/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 commented 12 months ago

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 commented 11 months ago

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 commented 10 months ago

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 commented 9 months ago

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

thekid commented 9 months ago
image

Source: https://star-history.com/#bigskysoftware/htmx&Date