Chapter 5 work

This commit is contained in:
Carson Gross 2022-07-22 13:51:34 -06:00
parent af0df21200
commit 7428c2ecd4
9 changed files with 11422 additions and 1365 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -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
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
Another somewhat common pattern for dealing with long lists of things is known as "infinite scroll", where,

View File

@ -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
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
de-emphasized their native request indicators over time, and it is doubly unfortunate that request indicators are not
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
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!)

View File

@ -5,7 +5,7 @@
/Creator (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')
/CreationDate (D:20220718164528-06'00')
/CreationDate (D:20220722135105-06'00')
>>
endobj
2 0 obj

View File

@ -5,7 +5,7 @@
/Creator (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')
/CreationDate (D:20220718164535-06'00')
/CreationDate (D:20220722135111-06'00')
>>
endobj
2 0 obj

View File

@ -5,7 +5,7 @@
/Creator (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')
/CreationDate (D:20220718164536-06'00')
/CreationDate (D:20220722135112-06'00')
>>
endobj
2 0 obj

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff