mirror of
https://github.com/bigskysoftware/hypermedia-systems.git
synced 2025-12-02 00:03:07 -05:00
Chapter 5 work
This commit is contained in:
parent
af0df21200
commit
7428c2ecd4
BIN
images/screenshot_stacked_actions.png
Normal file
BIN
images/screenshot_stacked_actions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
images/screenshot_total_contacts.png
Normal file
BIN
images/screenshot_total_contacts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@ -892,6 +892,18 @@ that has just such a button in it:
|
|||||||
Believe it or not, that's all we need to change to enable a "Click To Load" style UI! No server side changes are necessary
|
Believe it or not, that's all we need to change to enable a "Click To Load" style UI! No server side changes are necessary
|
||||||
because of the flexibility that htmx gives you with respect to how we process server responses. Pretty cool, eh?
|
because of the flexibility that htmx gives you with respect to how we process server responses. Pretty cool, eh?
|
||||||
|
|
||||||
|
==== Relative Positional Targets
|
||||||
|
|
||||||
|
Here we saw the first example of a target that was "relatively positioned": `closest tr`. The `closest` keyword indicates
|
||||||
|
that the closest parent that matches the following CSS selector is the target. So in this example the target was the
|
||||||
|
`tr` that was enclosing the button.
|
||||||
|
|
||||||
|
htmx also supports `next` and `previous` relative positional expressions, allowing you to target the next element or
|
||||||
|
previous element that matches a given CSS selector.
|
||||||
|
|
||||||
|
Relative positional expressions like this are quite powerful and allow you to avoid having to generate `id` attributes
|
||||||
|
in your HTML just so you can target a particular element.
|
||||||
|
|
||||||
=== Infinite Scroll
|
=== Infinite Scroll
|
||||||
|
|
||||||
Another somewhat common pattern for dealing with long lists of things is known as "infinite scroll", where,
|
Another somewhat common pattern for dealing with long lists of things is known as "infinite scroll", where,
|
||||||
|
|||||||
@ -393,9 +393,480 @@ anything as an indicator and it will be hidden by default, and will be shown whe
|
|||||||
done via standard CSS classes, allowing you to control the transitions and even the mechanism by which the indicator
|
done via standard CSS classes, allowing you to control the transitions and even the mechanism by which the indicator
|
||||||
is show (e.g. you might use `display` rather than `opacity`). htmx is flexible in this regard.
|
is show (e.g. you might use `display` rather than `opacity`). htmx is flexible in this regard.
|
||||||
|
|
||||||
|
.Use Request Indicators!
|
||||||
|
****
|
||||||
Request indicators are an important UX aspect of any distributed application. It is unfortunate that browsers have
|
Request indicators are an important UX aspect of any distributed application. It is unfortunate that browsers have
|
||||||
de-emphasized their native request indicators over time, and it is doubly unfortunate that request indicators are not
|
de-emphasized their native request indicators over time, and it is doubly unfortunate that request indicators are not
|
||||||
part of the JavaScript ajax APIs.
|
part of the JavaScript ajax APIs.
|
||||||
|
|
||||||
Be sure not to neglect this important aspect of your application! Even though requests might seem instant when you are
|
Be sure not to neglect this important aspect of your application! Even though requests might seem instant when you are
|
||||||
working on your application locally, in the real world
|
working on your application locally, in the real world
|
||||||
|
****
|
||||||
|
|
||||||
|
So there we go: we now have a pretty darned sophisticated user experience built out when compared with plain HTML, but
|
||||||
|
we've built it all as a hypermedia-driven feature, no JSON or JavaScript to be seen! This particular implementation also
|
||||||
|
has the benefit of being a progressive enhancement, so this aspect of our application will continue to work for clients
|
||||||
|
that don't have JavaScript enabled. Pretty slick!
|
||||||
|
|
||||||
|
== Lazy Loading
|
||||||
|
|
||||||
|
With active search behind us, let's move on to a very different sort of problem, that of lazy loading. Lazy loading is
|
||||||
|
when the loading of something is deferred until later, when needed. This is commonly used as a performance enhancement:
|
||||||
|
you avoid the processing resources necessary to produce some data until that data is actually needed.
|
||||||
|
|
||||||
|
Let's add a count of the total number of contacts below the bottom of our contacts table. This will give us a potentially
|
||||||
|
expensive operation that we can use to demonstrate how easy it is to add lazy loading to our application using htmx.
|
||||||
|
|
||||||
|
First let's update our server code in the `/contacts` request handler to get a count of the total number of contacts.
|
||||||
|
We will pass that count through to the template to render some new HTML.
|
||||||
|
|
||||||
|
.Adding A Count To The UI
|
||||||
|
[source,python]
|
||||||
|
----
|
||||||
|
@app.route("/contacts")
|
||||||
|
def contacts():
|
||||||
|
search = request.args.get("q")
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
count = Contact.count() <1>
|
||||||
|
if search:
|
||||||
|
contacts_set = Contact.search(search)
|
||||||
|
if request.headers.get('HX-Trigger') == 'search':
|
||||||
|
return render_template("rows.html", contacts=contacts_set, page=page, count=count)
|
||||||
|
else:
|
||||||
|
contacts_set = Contact.all(page)
|
||||||
|
return render_template("index.html", contacts=contacts_set, page=page, count=count) <2>
|
||||||
|
----
|
||||||
|
<1> Get the total count of contacts from the Contact model
|
||||||
|
<2> Pass the count out to the `index.html` template to use when rendering
|
||||||
|
|
||||||
|
Now, as with the rest of the application, we are not going to focus on the internals of `Contact.count()`, we are just
|
||||||
|
going to take it for granted that:
|
||||||
|
|
||||||
|
* It returns the total count of contacts in the contact database
|
||||||
|
* It may potentially be slow
|
||||||
|
|
||||||
|
Next lets add some HTML to our `index.html` that takes advantage of this new bit of data, showing a message next
|
||||||
|
to the "Add Contact" link with the total count of users. Here is what our HTML looks like:
|
||||||
|
|
||||||
|
.Adding A Contact Count Element To The Application
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<p>
|
||||||
|
<a href="/contacts/new">Add Contact</a> <span>({{ count }} total Contacts)</span><1>
|
||||||
|
</p>
|
||||||
|
----
|
||||||
|
<1> A simple span with some text showing the total number of contacts.
|
||||||
|
|
||||||
|
Well that was easy, wasn't it? Now our users will see the total number of contacts next to the link to add new
|
||||||
|
contacts, to give them a sense of how large the contact database is. This sort of rapid development is one of the
|
||||||
|
joys of developing web applications the old way.
|
||||||
|
|
||||||
|
Here is what the feature looks like in our application:
|
||||||
|
|
||||||
|
[#figure-5-1, reftext="Figure {chapter}.{counter:figure}"]
|
||||||
|
.Total Contact Count Display
|
||||||
|
image::../images/screenshot_total_contacts.png[]
|
||||||
|
|
||||||
|
Beautiful.
|
||||||
|
|
||||||
|
Of course, as you probably suspected, all it not perfect. Unfortunately, upon shipping this feature to production, we
|
||||||
|
start getting some complaints from the users that the application "feels slow". So, like all good developers faced with
|
||||||
|
a performance issues, rather than guessing what the issue might be, we try to get a performance profile of the application
|
||||||
|
to see what exactly is causing the problem.
|
||||||
|
|
||||||
|
It turns out, surprisingly, that the problem is that innocent looking `Contacts.count()` call, which is taking up to
|
||||||
|
a second and a half to complete. Unfortunately, for reasons beyond the scope of this book, it is not possible to improve
|
||||||
|
that load time, nor it is also not possible to cache the result. That leaves us with two choices:
|
||||||
|
|
||||||
|
* Remove the feature
|
||||||
|
* Come up with some other way to mitigate the performance issue
|
||||||
|
|
||||||
|
After talking with the project manager, it is clear that removing the feature isn't an acceptable solution, so we
|
||||||
|
will need to take another approach to mitigating the performance issue. And the approach we will use is lazy loading,
|
||||||
|
where we defer loading the count "until later". Let's look at exactly how we can accomplish this using htmx.
|
||||||
|
|
||||||
|
=== Pulling The Expensive Code Out
|
||||||
|
|
||||||
|
The first step is to pull the expensive code, that is, the call to `Contacts.count()` out of request handerl for the
|
||||||
|
`/contacts` end point.
|
||||||
|
|
||||||
|
Let's move it to a new end point at `/contacts/count` instead. We won't need to render a template with this new end
|
||||||
|
point. It's only job is going to be to render that small bit of text that is in the span, "(22 total Contacts)"
|
||||||
|
|
||||||
|
Here is what the new code will look like:
|
||||||
|
|
||||||
|
.Pulling The Expensive Code Out
|
||||||
|
[source,python]
|
||||||
|
----
|
||||||
|
@app.route("/contacts")
|
||||||
|
def contacts():
|
||||||
|
search = request.args.get("q")
|
||||||
|
page = int(request.args.get("page", 1)) <1>
|
||||||
|
if search:
|
||||||
|
contacts_set = Contact.search(search)
|
||||||
|
if request.headers.get('HX-Trigger') == 'search':
|
||||||
|
return render_template("rows.html", contacts=contacts_set, page=page)
|
||||||
|
else:
|
||||||
|
contacts_set = Contact.all(page)
|
||||||
|
return render_template("index.html", contacts=contacts_set, page=page) <2>
|
||||||
|
|
||||||
|
@app.route("/contacts/count")
|
||||||
|
def contacts_count():
|
||||||
|
count = Contact.count() <3>
|
||||||
|
return "(" + str(count) + " total Contacts)" <4>
|
||||||
|
|
||||||
|
----
|
||||||
|
<1> We no longer call `Contacts.count()` in this handler
|
||||||
|
<2> `count` is no longer passed out to the template to render in the `/contacts` handler
|
||||||
|
<3> We create a new handler at the `/contacts/count` path that does the expensive calculation
|
||||||
|
<4> Return the string with the total number of contacts in it
|
||||||
|
|
||||||
|
Great! Now we have an end point that will produce that expensive-to-create text for us. The next step is to hook up
|
||||||
|
the span we have created to actually retrieve this text. As we said earlier, the default behavior of htmx is to
|
||||||
|
place any content it recieves for a given request into the `innerHTML` of an element, which is exactly what we want here:
|
||||||
|
we want to retrieve this text and put it into the `span`. We can use the `hx-get` attribute pointing to this new path
|
||||||
|
to do exactly that.
|
||||||
|
|
||||||
|
However, recall that the default _event_ that will trigger a request for a `span` element in htmx is the `click` event.
|
||||||
|
That's not what we want. Instead, we want this to trigger passively, when the page loads. To do this, we can use
|
||||||
|
the `hx-trigger` attribute with the value `load`. This will cause htmx to issue the request when the element is loaded
|
||||||
|
into the page.
|
||||||
|
|
||||||
|
Here is our updated template code:
|
||||||
|
|
||||||
|
.Adding A Contact Count Element To The Application
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<p>
|
||||||
|
<a href="/contacts/new">Add Contact</a> <span hx-get="/contacts/count" hx-trigger="load"</span><1>
|
||||||
|
</p>
|
||||||
|
----
|
||||||
|
<1> Issue a `GET` to `/contacts/count` when the `load` event occurs
|
||||||
|
|
||||||
|
Note that the `span` starts empty: we have removed the content from it, and we are allowing the request to `/contacts/count`
|
||||||
|
to populate it instead.
|
||||||
|
|
||||||
|
And, lo and behold, our `/contacts` page is fast again! When you navigate to the page it feels very snappily and
|
||||||
|
profiling shows that yes, indeed, the page is loading much more quickly. Why is that? We'll we've deferred the
|
||||||
|
expensive calculation to a secondary request, allowing the initial request to finish loading much more quickly.
|
||||||
|
|
||||||
|
You might say "OK, great, but it's still taking a second or two to get the total count on the page." That's true, but
|
||||||
|
often the user may not be particularly interested in the total count. They may just want to come to the page and
|
||||||
|
search for an existing user, or perhaps they may want to edit or add a user. The total count
|
||||||
|
is often just a "nice to have" bit of information in these cases. By deferring the calculation of the count in this manner
|
||||||
|
we let users get on with their use of the application while we perform the expensive calculation.
|
||||||
|
|
||||||
|
Yes, the total time to get all the information on the screen takes just as long. (It actually might be a bit longer since
|
||||||
|
we now have two requests that need to get all the information.) But the _percieved performance_ for the end user will
|
||||||
|
be much better: they can do what they want nearly immediately, even if some information isn't available instantaneously.
|
||||||
|
This is a great tool to have in your toolbelt when optimizing your web application performance!
|
||||||
|
|
||||||
|
=== Adding An Indicator
|
||||||
|
|
||||||
|
Unfortunately there is one somewhat disconcerting aspect to our current implementation: the count is lazily loaded,
|
||||||
|
but there is no way for a user to know that this computation is being done. As it stands, the count just sort of
|
||||||
|
bursts onto the scene whenever the request to `/contacts/count` completes.
|
||||||
|
|
||||||
|
That's not ideal. What we want is an indicator, like we added to our active search example. And, in fact, we can
|
||||||
|
simply reuse the same spinner image here!
|
||||||
|
|
||||||
|
Now, in this case, we have a one-time request and nce the request is over, we are not going to need the spinner anymore.
|
||||||
|
So it doesn't make sense to use the exact approach we did with the active search example, placing a spinner beside the
|
||||||
|
span and using the `hx-indicator` attribute to point to it.
|
||||||
|
|
||||||
|
Instead, we can put the spinner _inside_ the content of the span. When the request completes the content in the response
|
||||||
|
will be placed inside the span, replacing the spinner with the computed contact count. It turns out that htmx allows
|
||||||
|
you to place indicators with the `htmx-indicator` class on them inside of elements that are htmx-powered and, in the
|
||||||
|
absence of an `hx-indicator` attribute, these internal indicators will be shown when a request is in flight.
|
||||||
|
|
||||||
|
So let's add that spinner from the active search example as the initial content in our span:
|
||||||
|
|
||||||
|
.Adding An Indicator To Our Lazily Loaded Content
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<span hx-get="/contacts/count" hx-trigger="load">
|
||||||
|
<img id="spinner" class="htmx-indicator" src="/static/img/spinning-circles.svg"/><1>
|
||||||
|
</span>
|
||||||
|
----
|
||||||
|
<1> Yep, that's it
|
||||||
|
|
||||||
|
Great! Now when the user loads the page, rather than having the total contact count sprung on them like a surprise,
|
||||||
|
there is a nice spinner indicating that something is coming. Much better!
|
||||||
|
|
||||||
|
Note that all we had to do was copy and paste our indicator from the active search example into the `span`! This is
|
||||||
|
a great demonstration of how htmx provides flexible, composable features and building blocks to work with: implementing
|
||||||
|
a new feature is often just a copy-and-paste, with maybe a tweak or two, and you are done.
|
||||||
|
|
||||||
|
=== But That's Not Lazy!
|
||||||
|
|
||||||
|
You might say "OK, but that's not really lazy. We are still loading the count immediately when the page is loaded,
|
||||||
|
we are just doing it in a second request. You aren't really waiting until the value is actually needed."
|
||||||
|
|
||||||
|
Fine. Let's make it _lazy_ lazy: we'll only issue the request when the span scrolls into view.
|
||||||
|
|
||||||
|
To do that, just recall how we set up the infinite scroll example: we used the `revealed` event for our trigger. That's
|
||||||
|
all we want here, right? When the element is revealed we issue the request.
|
||||||
|
|
||||||
|
.Making It Lazy Lazy
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<span hx-get="/contacts/count" hx-trigger="revealed"> <1>
|
||||||
|
<img id="spinner" class="htmx-indicator" src="/static/img/spinning-circles.svg"/>
|
||||||
|
</span>
|
||||||
|
----
|
||||||
|
<1> Yep, that's it
|
||||||
|
|
||||||
|
Now we have a truly lazy implementation, deferring the expensive computation until we are absolutely sure we need it. A
|
||||||
|
pretty cool trick, and, again, a simple one-attribute change demonstrates the flexibility of both htmx the hypermedia
|
||||||
|
approach.
|
||||||
|
|
||||||
|
== Inline Delete
|
||||||
|
|
||||||
|
We now have some pretty slick UX patterns in our application, but let's not rest on our laurels. For our next
|
||||||
|
hypermedia trick, we are going to implement "inline delete", where a contact can be deleted directly from the
|
||||||
|
list view of all contacts, rather than requiring the user to drill in to the edit view of particular contact to access
|
||||||
|
the "Delete Contact" button.
|
||||||
|
|
||||||
|
We already have "Edit" and "View" links for each row, in the `rows.html` template:
|
||||||
|
|
||||||
|
.The Existing Row Actions
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<td>
|
||||||
|
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||||
|
<a href="/contacts/{{ contact.id }}">View</a>
|
||||||
|
</td>
|
||||||
|
----
|
||||||
|
|
||||||
|
We want to add a "Delete" link as well. And we want that link to act an awful lot like the "Delete Contact" from
|
||||||
|
`edit.html`, don't we? We'd like to issue an HTTP `DELETE` to the URL for the given contact, we want a confirmation
|
||||||
|
dialog to ensure the user doesn't accidentally delete a contact. Here is the "Delete Contact" html:
|
||||||
|
|
||||||
|
.The Existing Row Actions
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<button hx-delete="/contacts/{{ contact.id }}"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-confirm="Are you sure you want to delete this contact?"
|
||||||
|
hx-target="body">
|
||||||
|
Delete Contact
|
||||||
|
</button>
|
||||||
|
----
|
||||||
|
|
||||||
|
Is this going to be another copy-and-paste job with a bit of tweaking? It sure is!
|
||||||
|
|
||||||
|
One thing to note is that, in the case of the "Delete Contact" button, we want to rerender the whole screen and update
|
||||||
|
the URL, since we are going to be returning from the edit view for the contact to the list view of all contacts. In
|
||||||
|
the case of this link, however, we are already on the list of contacts, so there is no need to update the URL, and
|
||||||
|
we can omit the `hx-push-url` attribute.
|
||||||
|
|
||||||
|
Here is our updated code:
|
||||||
|
|
||||||
|
.The Existing Row Actions
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<td>
|
||||||
|
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||||
|
<a href="/contacts/{{ contact.id }}">View</a>
|
||||||
|
<a href="#" hx-delete="/contacts/{{ contact.id }}"
|
||||||
|
hx-confirm="Are you sure you want to delete this contact?"
|
||||||
|
hx-target="body">Delete</a> <1>
|
||||||
|
</td>
|
||||||
|
----
|
||||||
|
<1> Almost a straight copy of the "Delete Contact" button
|
||||||
|
|
||||||
|
As you can see, we have added a new anchor tag and given it a blank target (the `#` value in its `href` attribute) to
|
||||||
|
retain the correct mouse-over styling behavior of the link. We've also copied the `hx-delete`, `hx-confirm` and
|
||||||
|
`hx-target` attributes from the "Delete Contact" button, but omitted the `hx-push-url` attributes since we don't want
|
||||||
|
to update the URL of the browser.
|
||||||
|
|
||||||
|
And... that's it! We now have inline delete working, even with a confirmation dialog!
|
||||||
|
|
||||||
|
. A Style Sidebar
|
||||||
|
****
|
||||||
|
One thing is really starting to bother me about our application: we now have quite a few actions stacking up in our
|
||||||
|
contacts table, and it is starting to look very distracting:
|
||||||
|
|
||||||
|
[#figure-5-1, reftext="Figure {chapter}.{counter:figure}"]
|
||||||
|
.That's a Lot of Actions
|
||||||
|
image::../images/screenshot_stacked_actions.png[]
|
||||||
|
|
||||||
|
It would be nice if we didn't show the actions all in a row, and it would be nice if we only showed the actions when
|
||||||
|
the user indicated interest in a given row. We will return to this problem after we look at the relationship between
|
||||||
|
scripting and a Hypermedia Driven Application in a later chapter.
|
||||||
|
|
||||||
|
For now, let's just tolerate this less-than-ideal user interface, knowing that we will return to it later.
|
||||||
|
****
|
||||||
|
|
||||||
|
=== Getting Fancy
|
||||||
|
|
||||||
|
We can get even fancier here, however. What if, rather than re-rendering the whole page, we just removed the row
|
||||||
|
for the contact? The user is looking at the row anyway, so is there really a need to re-render the whole page?
|
||||||
|
|
||||||
|
To do this, we'll need to do a couple of things:
|
||||||
|
|
||||||
|
* We'll need to update this link to target the row that it is in
|
||||||
|
* We'll need to change the swap to `outerHTML`, since we want to replace (really, remove) the entire row
|
||||||
|
* We'll need to update the server side to render empty content when the `DELETE` is issued from a row rather
|
||||||
|
than from the "Delete Contact" button on the contact edit page
|
||||||
|
|
||||||
|
First things first, update the target of our "Delete" link to be the row that the link is in, rather than the entire
|
||||||
|
body. We can once again take advantage of the relative positional `closest` feature to target the closest `tr`, like
|
||||||
|
we did in our "Click To Load" and "Infinite Scroll" features:
|
||||||
|
|
||||||
|
.The Existing Row Actions
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<td>
|
||||||
|
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||||
|
<a href="/contacts/{{ contact.id }}">View</a>
|
||||||
|
<a href="#" hx-delete="/contacts/{{ contact.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Are you sure you want to delete this contact?"
|
||||||
|
hx-target="closest tr">Delete</a> <1>
|
||||||
|
</td>
|
||||||
|
----
|
||||||
|
<1> Updated to target the closest enclosing `tr` (table row) of the link
|
||||||
|
|
||||||
|
Now we need to update the server side as well. We want to keep the "Delete Contact" button working as well, and in
|
||||||
|
that case the current logic is correct. So we'll need some way to differentiate between `DELETE` requests that are
|
||||||
|
triggered by the button and `DELETE` requests that come from this anchor.
|
||||||
|
|
||||||
|
The cleanest way to do this is to add an `id` attribute to the "Delete Contact" button, so that we can inspect the
|
||||||
|
`HX-Trigger` HTTP Request header to determine if the delete button was the cause of the request. This is a simple
|
||||||
|
change to the existing HTML:
|
||||||
|
|
||||||
|
.Adding an `id` to the "Delete Contact" button
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<button id="delete-btn" <1>
|
||||||
|
hx-delete="/contacts/{{ contact.id }}"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-confirm="Are you sure you want to delete this contact?"
|
||||||
|
hx-target="body">
|
||||||
|
Delete Contact
|
||||||
|
</button>
|
||||||
|
----
|
||||||
|
<1> An `id` attribute has been added to the button
|
||||||
|
|
||||||
|
With this in place, we now have a mechanism for differentiating between the delete button in the `edit.html` template and
|
||||||
|
the delete links in the `rows.html` template. We can write code very similar to what we did for the active search pattern,
|
||||||
|
using a conditional on the `HX-Trigger` header to determine what we want to do. If that header has the value `delete-btn`,
|
||||||
|
then we know the request came from the button on the edit page, and we can do what we are currently doing: delete the
|
||||||
|
contact and redirect to `/contacts` page.
|
||||||
|
|
||||||
|
If it does not have that value, then we can simple delete the contact and return an empty string. This empty string
|
||||||
|
will replace the target, in this case the row for the given contact, thereby removing the row from the UI.
|
||||||
|
|
||||||
|
Let's make that change to our server side code:
|
||||||
|
|
||||||
|
.Updating Our Server Code To Handle Two Different Delete Patterns
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
@app.route("/contacts/<contact_id>", methods=["DELETE"])
|
||||||
|
def contacts_delete(contact_id=0):
|
||||||
|
contact = Contact.find(contact_id)
|
||||||
|
contact.delete()
|
||||||
|
if request.headers.get('HX-Trigger') == 'delete-brn': <1>
|
||||||
|
flash("Deleted Contact!")
|
||||||
|
return redirect("/contacts", 303)
|
||||||
|
else:
|
||||||
|
return "" <2>
|
||||||
|
----
|
||||||
|
<1> If the delete button on the edit page submitted this request, then continue to do the logic we had previous
|
||||||
|
<2> If not, simply return an empty string, which will delete the row
|
||||||
|
|
||||||
|
Believe it or not, we are now done: when a user clicks "Delete" on a contact row and confirms the delete, the row will
|
||||||
|
disappear from the UI. Poof! Once again, we have a situation where just changing a few lines of simple code gives us a
|
||||||
|
dramatically different behavior. Hypermedia is very powerful!
|
||||||
|
|
||||||
|
=== Getting Fancy Fancy With The htmx Swapping Model
|
||||||
|
|
||||||
|
This is pretty cool, but there is another improvement we can make if we take some time to understand the htmx content
|
||||||
|
swapping model: it sure would be sexy if, rather than just instantly deleting the row, we faded it out before we removed
|
||||||
|
it. That easement makes it more obvious that the row is being removed, giving the user some nice visual feedback on the
|
||||||
|
modification.
|
||||||
|
|
||||||
|
It turns out we can do this pretty easily with htmx, but to do so we'll need to dig in to exactly how htmx swaps content.
|
||||||
|
You might think that htmx simply puts the new content into the DOM, but that's not how it works. Instead, content goes
|
||||||
|
through a series of steps as it is added to the DOM:
|
||||||
|
|
||||||
|
* When content is recieved and about to be swapped into the DOM, the `htmx-swapping` CSS class is added to the target
|
||||||
|
element
|
||||||
|
* A small delay then occurs (we will discuss why in a moment)
|
||||||
|
* Next, the `htmx-swapping` class is removed from the target and the `htmx-settling` class is added
|
||||||
|
* The new content is swapped into the DOM
|
||||||
|
* Another small delay occurs
|
||||||
|
* Finally, the `htmx-settling` class is removed from the target
|
||||||
|
|
||||||
|
There is more to the swap mechanic, settling for example is a more advanced topic that we will discuss in a later chapter,
|
||||||
|
but for now this is all you need to know about the swapping mechanism. Now, there are small delays in the process here,
|
||||||
|
typically on the order of a few milliseconds. Why so?
|
||||||
|
|
||||||
|
It turns out that these small delays allow _CSS transitions_ to occur.
|
||||||
|
|
||||||
|
CSS transitions are a technology that allow you to animate a transition from one style to another. So, for example, if
|
||||||
|
you changed the height of something from 10 pixels to 20 pixels, by using a CSS transition you can make the element
|
||||||
|
smoothly animate to the new height. These sorts of animations are fun, often increase application usability, and are ::
|
||||||
|
great mechanism to add polish and fit-and-finish to your web application.
|
||||||
|
|
||||||
|
Unfortunately, CSS transitions are not available in plain HTML: you have to use JavaScript and add or remove classes
|
||||||
|
to get them to trigger. This is exactly why the htmx swap model is more complicated than you might think: by swapping
|
||||||
|
in classes and adding small delays, you can access CSS transitions purely from hypermedia.
|
||||||
|
|
||||||
|
=== Taking Advantage of `htmx-swapping`
|
||||||
|
|
||||||
|
OK, so, let's go back and look at our inline delete mechanic: we click an htmx enhanced link which deletes the contact
|
||||||
|
and then swaps some empty content in for the row. We know that, before the `tr` element is removed, it will have the
|
||||||
|
`htmx-swapping` class added to it. We can take advantage of that to write a CSS transition that fades the opacity of
|
||||||
|
the row to 0. Here is what that CSS looks like:
|
||||||
|
|
||||||
|
.Adding A Fade Out Transition
|
||||||
|
[source, css]
|
||||||
|
----
|
||||||
|
tr.htmx-swapping { <1>
|
||||||
|
opacity: 0; <2>
|
||||||
|
transition: opacity 1s ease-out; <3>
|
||||||
|
}
|
||||||
|
----
|
||||||
|
<1> We want this style to apply to `tr` elements with the `htmx-swapping` class on them
|
||||||
|
<2> The `opacity` will be 0, making it invisible
|
||||||
|
<3> The `opacity` will transition to 0 over a 1 second time period, using the `ease-out` function
|
||||||
|
|
||||||
|
Again, this is not a CSS book and I am not going to go deeply into the details of CSS transitions, but hopefully the
|
||||||
|
above makes sense to you, even if this is the first time you've seen CSS transitions.
|
||||||
|
|
||||||
|
So, think about what this means from the htmx swapping model: when htmx gets content back to swap into the row it will
|
||||||
|
put the `htmx-swapping` class on the row and wait a bit. This will allow the transition to a zero opacity to occur,
|
||||||
|
fading the row out. Then the new (empty) content will be swapped in, which will effectively removing the row.
|
||||||
|
|
||||||
|
Sounds good, and we are nearly there. There is one more thing we need to do: the default "swap delay" for htmx is very
|
||||||
|
short, a few milliseconds. That makes sense in most cases: you don't want to have much of a delay before you put the
|
||||||
|
new content into the DOM. But, in this case, we want to give the CSS animation time to complete before we do the swap,
|
||||||
|
we want to give it a second, in fact.
|
||||||
|
|
||||||
|
Fortunately htmx has an option for the `hx-swap` annotation that allows you to set the swap delay: following the swap
|
||||||
|
type you can add `swap:` followed by a timing value to tell htmx to wait a specific amount of time before it swaps. Let's
|
||||||
|
update our HTML to allow a one second delay before the swap is done for the delete action:
|
||||||
|
|
||||||
|
.The Existing Row Actions
|
||||||
|
[source, html]
|
||||||
|
----
|
||||||
|
<td>
|
||||||
|
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||||
|
<a href="/contacts/{{ contact.id }}">View</a>
|
||||||
|
<a href="#" hx-delete="/contacts/{{ contact.id }}"
|
||||||
|
hx-swap="outerHTML swap:1s" <1>
|
||||||
|
hx-confirm="Are you sure you want to delete this contact?"
|
||||||
|
hx-target="closest tr">Delete</a>
|
||||||
|
</td>
|
||||||
|
----
|
||||||
|
<1> A swap delay changes how long htmx waits before it swaps in new content
|
||||||
|
|
||||||
|
With this modification, the existing row will stay in the DOM for an additional second, with the `htmx-swapping` class
|
||||||
|
on it. This will give the row time to transition to an opacity of zero, giving the fade out effect we want.
|
||||||
|
|
||||||
|
Now, when a user clicks on a "Delete" link and confirms the delete, the row will slowly fade out and then, once it has
|
||||||
|
faded to a 0 opacity, it will be removed. Fancy! And all done in a declarative, hypermedia oriented manner, no
|
||||||
|
JavaScript required! (Well, obviously htmx is written in JavaScript, but you know what I mean: we didn't have to write
|
||||||
|
any JavaScript to implement the feature!)
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||||
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||||
/ModDate (D:20220615142625-06'00')
|
/ModDate (D:20220615142625-06'00')
|
||||||
/CreationDate (D:20220718164528-06'00')
|
/CreationDate (D:20220722135105-06'00')
|
||||||
>>
|
>>
|
||||||
endobj
|
endobj
|
||||||
2 0 obj
|
2 0 obj
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||||
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||||
/ModDate (D:20220713173445-06'00')
|
/ModDate (D:20220713173445-06'00')
|
||||||
/CreationDate (D:20220718164535-06'00')
|
/CreationDate (D:20220722135111-06'00')
|
||||||
>>
|
>>
|
||||||
endobj
|
endobj
|
||||||
2 0 obj
|
2 0 obj
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||||
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||||
/ModDate (D:20220711173725-06'00')
|
/ModDate (D:20220711173725-06'00')
|
||||||
/CreationDate (D:20220718164536-06'00')
|
/CreationDate (D:20220722135112-06'00')
|
||||||
>>
|
>>
|
||||||
endobj
|
endobj
|
||||||
2 0 obj
|
2 0 obj
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user