mirror of
https://github.com/bigskysoftware/hypermedia-systems.git
synced 2025-12-09 00:03:43 -05:00
Merge branch 'main' of github.com:bigskysoftware/building-hypermedia-systems
This commit is contained in:
commit
157489ba65
@ -22,12 +22,12 @@ when Google adopted it for search results, and many applications now implement i
|
|||||||
To implement Active Search, we are going to use techniques closely related to the way we did email validation in the
|
To implement Active Search, we are going to use techniques closely related to the way we did email validation in the
|
||||||
previous chapter. If you think about it, the two features are similar in many ways: in both cases we want to issue
|
previous chapter. If you think about it, the two features are similar in many ways: in both cases we want to issue
|
||||||
a request as the user types into an input and then update some other element with a response. The server-side implementations
|
a request as the user types into an input and then update some other element with a response. The server-side implementations
|
||||||
will, of course, be very different, but the front end code will look fairly similar, a testament to how general the "`issue
|
will, of course, be very different, but the front-end code will look fairly similar due to htmx's general approach of "`issue
|
||||||
a request on an event and replace something on the screen`" approach that htmx takes really is.
|
a request on an event and replace something on the screen.`"
|
||||||
|
|
||||||
=== Our Current Search UI
|
=== Our Current Search UI
|
||||||
|
|
||||||
Let's recall what the current search field in our application currently looks like:
|
Let's recall what the search field in our application currently looks like:
|
||||||
|
|
||||||
.Our search form
|
.Our search form
|
||||||
[source,html]
|
[source,html]
|
||||||
@ -47,24 +47,23 @@ As it stands right now, the user must hit enter when the search input is focused
|
|||||||
of these events will trigger a `submit` event on the form, causing it to issue an HTTP `GET` and re-rendering the whole
|
of these events will trigger a `submit` event on the form, causing it to issue an HTTP `GET` and re-rendering the whole
|
||||||
page.
|
page.
|
||||||
|
|
||||||
Currently, thanks to `hx-boost`, the form will use an AJAX request for this `GET`, but we currently don't get that nice
|
Currently, thanks to `hx-boost`, the form will use an AJAX request for this `GET`, but we don't yet get that nice
|
||||||
search-as-you-type behavior that we want.
|
search-as-you-type behavior we want.
|
||||||
|
|
||||||
=== Adding Active Search
|
=== Adding Active Search
|
||||||
|
|
||||||
To add active search behavior, we will need to add a few htmx attributes to the search input. We will leave the current
|
To add active search behavior, we will attach a few htmx attributes to the search input. We will leave the current form as it is, with an `action` and `method`, so that the normal
|
||||||
form as it is, with an `action` and `method`, so that, in case a user does not have JavaScript enabled, the normal
|
search behavior works even if a user does not have JavaScript enabled. This will make our "`Active Search`" improvement a nice "`progressive enhancement.`"
|
||||||
search behavior continues to work. This will make our "`Active Search`" improvement a nice "`progressive enhancement`".
|
|
||||||
|
|
||||||
So, in addition to the regular form behavior, we _also_ want to issue an HTTP `GET` request when a key up occurs. We want
|
So, in addition to the regular form behavior, we _also_ want to issue an HTTP `GET` request when a key up occurs. We want
|
||||||
to issue this request to the same URL as the normal form submission. Finally, we only want to do this after a small
|
to issue this request to the same URL as the normal form submission. Finally, we only want to do this after a small
|
||||||
pause in typing has occurred.
|
pause in typing has occurred.
|
||||||
|
|
||||||
As we said, this functionality is very similar to what we needed for email validation isn't it? We can, in fact copy
|
As we said, this functionality is very similar to what we needed for email validation. We can, in fact copy
|
||||||
the `hx-trigger` attribute directly from our email validation example, with its small 200-millisecond delay, to allow a
|
the `hx-trigger` attribute directly from our email validation example, with its small 200-millisecond delay, to allow a
|
||||||
user to stop typing before a request is triggered.
|
user to stop typing before a request is triggered.
|
||||||
|
|
||||||
Again, a great example of how common patterns come up again and again when you are using htmx.
|
This is another example of how common patterns come up again and again when using htmx.
|
||||||
|
|
||||||
.Adding active search behavior
|
.Adding active search behavior
|
||||||
[source,html]
|
[source,html]
|
||||||
@ -81,15 +80,15 @@ Again, a great example of how common patterns come up again and again when you a
|
|||||||
<2> Issue a `GET` to the same URL as the form.
|
<2> Issue a `GET` to the same URL as the form.
|
||||||
<3> Nearly the same `hx-trigger` specification as for the email input validation.
|
<3> Nearly the same `hx-trigger` specification as for the email input validation.
|
||||||
|
|
||||||
We did make a small change to the `hx-trigger` attribute: we switched out the `change` event for the `search` event.
|
We made a small change to the `hx-trigger` attribute: we switched out the `change` event for the `search` event.
|
||||||
The `search` event is triggered when someone clears the search or hits the enter key. It is a non-standard event, but
|
The `search` event is triggered when someone clears the search or hits the enter key. It is a non-standard event, but
|
||||||
it doesn't hurt to include here. The main functionality of the feature is provided by the second triggering event, the `keyup`
|
it doesn't hurt to include here. The main functionality of the feature is provided by the second triggering event, the `keyup`.
|
||||||
which, as with the email example, is delayed with the `delay:200ms` modifier to "`debounce`" the input requests and
|
As in the email example, this trigger is delayed with the `delay:200ms` modifier to "`debounce`" the input requests and
|
||||||
avoid hammering our server with requests on every keyup.
|
avoid hammering our server with requests on every keyup.
|
||||||
|
|
||||||
=== Targeting The Correct Element
|
=== Targeting The Correct Element
|
||||||
|
|
||||||
What we have is already pretty close to what we want, but we need to set up the correct target. Recall that the default
|
What we have is close to what we want, but we need to set up the correct target. Recall that the default
|
||||||
target for an element is itself. As things currently stand, an HTTP `GET` request will be issued to the `/contacts` path,
|
target for an element is itself. As things currently stand, an HTTP `GET` request will be issued to the `/contacts` path,
|
||||||
which will, as of now, return an entire HTML document of search results, and then this whole document will be inserted
|
which will, as of now, return an entire HTML document of search results, and then this whole document will be inserted
|
||||||
into the _inner_ HTML of the search input.
|
into the _inner_ HTML of the search input.
|
||||||
@ -113,7 +112,7 @@ the table of contacts:
|
|||||||
<label for="search">Search Term</label>
|
<label for="search">Search Term</label>
|
||||||
<input id="search" type="search" name="q" value="{{ request.args.get('q') or '' }}"
|
<input id="search" type="search" name="q" value="{{ request.args.get('q') or '' }}"
|
||||||
hx-get="/contacts"
|
hx-get="/contacts"
|
||||||
hx-trigger="change, keyup delay:200ms changed"
|
hx-trigger="search, keyup delay:200ms changed"
|
||||||
hx-target="tbody"/> <1>
|
hx-target="tbody"/> <1>
|
||||||
<input type="submit" value="Search"/>
|
<input type="submit" value="Search"/>
|
||||||
</form>
|
</form>
|
||||||
@ -133,8 +132,7 @@ Now if you try typing something into the search box, we'll see some results: a r
|
|||||||
into the document within the `tbody`. Unfortunately, the content that is coming back is still an entire HTML document.
|
into the document within the `tbody`. Unfortunately, the content that is coming back is still an entire HTML document.
|
||||||
|
|
||||||
Here we end up with a "`double render`" situation, where an entire document has been inserted _inside_ another element, with
|
Here we end up with a "`double render`" situation, where an entire document has been inserted _inside_ another element, with
|
||||||
all the navigation, headers and footers and so forth re-rendered within that element. This is an example of one of those
|
all the navigation, headers and footers and so forth re-rendered within that element. This is an example of one of those mis-targeting issues we mentioned earlier.
|
||||||
silly mis-targeting issues we mentioned earlier.
|
|
||||||
|
|
||||||
Thankfully, it is pretty easy to fix.
|
Thankfully, it is pretty easy to fix.
|
||||||
|
|
||||||
@ -167,12 +165,12 @@ bit_ of HTML, rather than a full document. Currently, we are letting the server
|
|||||||
and then, on the client side, we filter the HTML down to the bits that we want. This is easy to do, and, in fact, might
|
and then, on the client side, we filter the HTML down to the bits that we want. This is easy to do, and, in fact, might
|
||||||
be necessary if we don't control the server side or can't easily modify responses.
|
be necessary if we don't control the server side or can't easily modify responses.
|
||||||
|
|
||||||
In our application, however, since we are doing "`Full Stack`" development (that is: we control both the front end _and_ the back end
|
In our application, however, since we are doing "`Full Stack`" development (that is: we control both front-end _and_ back-end
|
||||||
code, and can easily modify either) we have another option: we can modify our server responses to return only the content
|
code, and can easily modify either) we have another option: we can modify our server responses to return only the content
|
||||||
necessary, and remove the need to do client-side filtering.
|
necessary, and remove the need to do client-side filtering.
|
||||||
|
|
||||||
This turns out to be more efficient, since we aren't returning all the content surrounding the bit we are interested in,
|
This turns out to be more efficient, since we aren't returning all the content surrounding the bit we are interested in,
|
||||||
saving bandwidth as well as CPU and memory on the server side. So let's take this opportunity to explore returning
|
saving bandwidth as well as CPU and memory on the server side. So let's explore returning
|
||||||
different HTML content based on the context information that htmx provides with the HTTP requests it makes.
|
different HTML content based on the context information that htmx provides with the HTTP requests it makes.
|
||||||
|
|
||||||
Here's a look again at the current server-side code for our search logic:
|
Here's a look again at the current server-side code for our search logic:
|
||||||
@ -229,9 +227,8 @@ Sec-GPC: 1
|
|||||||
TE: trailers
|
TE: trailers
|
||||||
----
|
----
|
||||||
|
|
||||||
|
Htmx takes advantage of this feature of HTTP and adds additional headers and, therefore, additional _context_ to the
|
||||||
htmx takes advantage of this feature of HTTP and adds additional headers and, therefore, additional _context_ to the
|
HTTP requests that it makes. This allows you to inspect those headers and choose
|
||||||
HTTP requests that it makes. This allows you to inspect those headers and make smarter decisions with respect to exactly
|
|
||||||
what logic to execute on the server, and what sort of HTML response you want to send to the client.
|
what logic to execute on the server, and what sort of HTML response you want to send to the client.
|
||||||
|
|
||||||
Here is a table of the HTTP headers that htmx includes in HTTP requests:
|
Here is a table of the HTTP headers that htmx includes in HTTP requests:
|
||||||
@ -281,16 +278,16 @@ def contacts():
|
|||||||
contacts_set = Contact.all()
|
contacts_set = Contact.all()
|
||||||
return render_template("index.html", contacts=contacts_set) <2>
|
return render_template("index.html", contacts=contacts_set) <2>
|
||||||
----
|
----
|
||||||
<1> If the request header `HX-Trigger` is equal to "`search`", we want to do something different.
|
<1> If the request header `HX-Trigger` is equal to "`search`" we want to do something different.
|
||||||
<2> We need to learn how to render just the table rows.
|
<2> We need to learn how to render just the table rows.
|
||||||
|
|
||||||
OK, so how do we render only the result rows?
|
OK, so how do we render only the result rows?
|
||||||
|
|
||||||
=== Factoring Your Templates
|
=== Factoring Your Templates
|
||||||
|
|
||||||
Now we come to what is a common pattern in htmx: we want to _factor_ our server-side templates. This means that we want to
|
Now we come to a common pattern in htmx: we want to _factor_ our server-side templates. This means that we want to
|
||||||
break our templates up a bit so that they can be called from multiple contexts. In this case, we want to break the rows of
|
break our templates up a bit so that they can be called from multiple contexts. In this case, we want to break the rows of
|
||||||
the results table out to a separate template. We will call this new template `rows.html` and we will include it from
|
the results table out to a separate template we will call `rows.html`. We will include it from
|
||||||
the original `index.html` template, and also use it in our controller to render it by itself when we want to respond with only the
|
the original `index.html` template, and also use it in our controller to render it by itself when we want to respond with only the
|
||||||
rows for Active Search requests.
|
rows for Active Search requests.
|
||||||
|
|
||||||
@ -377,10 +374,10 @@ The last step in factoring our templates is to modify our web controller to take
|
|||||||
file when it responds to an active search request.
|
file when it responds to an active search request.
|
||||||
|
|
||||||
Since `rows.html` is just another template, just like `index.html`, all we need to do is call the `render_template`
|
Since `rows.html` is just another template, just like `index.html`, all we need to do is call the `render_template`
|
||||||
function with `rows.html` rather than `index.html`, and we will render _only_ the row content rather than the entire
|
function with `rows.html` rather than `index.html`. This will render _only_ the row content rather than the entire
|
||||||
page:
|
page:
|
||||||
|
|
||||||
.Updating our server side search
|
.Updating our server-side search
|
||||||
[source,python]
|
[source,python]
|
||||||
----
|
----
|
||||||
@app.route("/contacts")
|
@app.route("/contacts")
|
||||||
@ -398,9 +395,9 @@ def contacts():
|
|||||||
|
|
||||||
Now, when an Active Search request is made, rather than getting an entire HTML document back, we only get a partial
|
Now, when an Active Search request is made, rather than getting an entire HTML document back, we only get a partial
|
||||||
bit of HTML, the table rows for the contacts that match the search. These rows are then inserted into the `tbody` on
|
bit of HTML, the table rows for the contacts that match the search. These rows are then inserted into the `tbody` on
|
||||||
the index page, without any need for an `hx-select` or any other client-side processing.
|
the index page, without any need for `hx-select` or other client-side processing.
|
||||||
|
|
||||||
And, as a bonus, the old form-based search still works as well, thanks to the fact that we conditionally render the rows
|
And, as a bonus, the old form-based search _still works_. We conditionally render the rows
|
||||||
only when the `search` input issues the HTTP request via htmx. Again, this is a progressive enhancement to our
|
only when the `search` input issues the HTTP request via htmx. Again, this is a progressive enhancement to our
|
||||||
application.
|
application.
|
||||||
|
|
||||||
@ -435,18 +432,17 @@ the browser's notion of history: if you click the back button it will take you t
|
|||||||
from. If you submit two searches and want to go back to the first one, you can simply hit back and the browser
|
from. If you submit two searches and want to go back to the first one, you can simply hit back and the browser
|
||||||
will "`return`" to that search.
|
will "`return`" to that search.
|
||||||
|
|
||||||
As it stands right now, during our Active Search, we are not updating the browser's navigation bar, so users aren't getting
|
As it stands right now, during our Active Search, we are not updating the browser's navigation bar. So, users aren't getting
|
||||||
nice copy-and-pasteable links and you aren't getting history entries either, so no back button support. Fortunately, htmx
|
nice copy-and-pasteable links and you aren't getting history entries either, which means no back button support. Fortunately, we've already seen how to fix this: with the `hx-push-url` attribute.
|
||||||
provides a way for fixing this that we've already seen: the `hx-push-url` attribute.
|
|
||||||
|
|
||||||
The `hx-push-url` attribute lets you tell htmx "`Please push the URL of this request into the browser's navigation bar`".
|
The `hx-push-url` attribute lets you tell htmx "`Please push the URL of this request into the browser's navigation bar.`"
|
||||||
Push might seem like an odd verb to use here, but that's the term that the underlying browser history API uses, which
|
Push might seem like an odd verb to use here, but that's the term that the underlying browser history API uses, which
|
||||||
stems from the fact that it models browser history as a "`stack`" of locations: when you go to a new location, that
|
stems from the fact that it models browser history as a "`stack`" of locations: when you go to a new location, that
|
||||||
location is "`pushed`" onto the stack of history elements, and when you click "`back`", that location is "`popped`" off
|
location is "`pushed`" onto the stack of history elements, and when you click "`back`", that location is "`popped`" off
|
||||||
the history stack.
|
the history stack.
|
||||||
|
|
||||||
So, to get proper history support for our Active Search, all we need to do is to set the `hx-push-url` attribute to
|
So, to get proper history support for our Active Search, all we need to do is to set the `hx-push-url` attribute to
|
||||||
`true`. Let's update our search input:
|
`true`.
|
||||||
|
|
||||||
.Updating the URL during active search
|
.Updating the URL during active search
|
||||||
[source, html]
|
[source, html]
|
||||||
@ -462,27 +458,26 @@ So, to get proper history support for our Active Search, all we need to do is to
|
|||||||
Now, as Active Search requests are sent, the URL in the browser's navigation bar is updated to have the proper query in
|
Now, as Active Search requests are sent, the URL in the browser's navigation bar is updated to have the proper query in
|
||||||
it, just like when the form is submitted.
|
it, just like when the form is submitted.
|
||||||
|
|
||||||
Now, you might not _want_ this behavior. You might feel it would be confusing to users to see the navigation bar updated
|
You might not _want_ this behavior. You might feel it would be confusing to users to see the navigation bar updated
|
||||||
and have history entries for every Active Search made, for example. Which is fine: you can simply omit the `hx-push-url`
|
and have history entries for every Active Search made, for example. Which is fine: you can simply omit the `hx-push-url`
|
||||||
attribute and it will go back to the behavior you want. Htmx tries to be flexible enough that you can achieve the UX
|
attribute and it will go back to the behavior you want. The goal with htmx is to be flexible enough to achieve the UX
|
||||||
that _you_ want, while staying within the declarative HTML model.
|
that _you_ want, while staying within the declarative HTML model.
|
||||||
|
|
||||||
=== Adding A Request Indicator
|
=== Adding A Request Indicator
|
||||||
|
|
||||||
A final touch for our Active Search pattern is to add a request indicator to let the user know that a search is in
|
A final touch for our Active Search pattern is to add a request indicator to let the user know that a search is in
|
||||||
progress. As it stands the user has to know that the active search functionality is doing a request implicitly and,
|
progress. As it stands the user has no explicit signal that the active search functionality is handling a request. If the search takes a bit, a user may end up thinking that the feature isn't working. By adding a request indicator we let
|
||||||
if the search takes a bit, may end up thinking that the feature isn't working. By adding a request indicator we let
|
|
||||||
the user know that the hypermedia application is busy and they should wait (hopefully not too long!) for the request to
|
the user know that the hypermedia application is busy and they should wait (hopefully not too long!) for the request to
|
||||||
complete.
|
complete.
|
||||||
|
|
||||||
htmx provides support for request indicators via the `hx-indicator` attribute. This attribute takes, you guessed it,
|
Htmx provides support for request indicators via the `hx-indicator` attribute. This attribute takes, you guessed it,
|
||||||
a CSS selector that points to the indicator for a given element. The indicator can be anything, but it is typically
|
a CSS selector that points to the indicator for a given element. The indicator can be anything, but it is typically
|
||||||
some sort of animated image, such as a gif or svg file, that spins or otherwise communicates visually that "`something
|
some sort of animated image, such as a gif or svg file, that spins or otherwise communicates visually that "`something
|
||||||
is happening`".
|
is happening.`"
|
||||||
|
|
||||||
Let's add a spinner after our search input:
|
Let's add a spinner after our search input:
|
||||||
|
|
||||||
.Updating the URL during active search
|
.Adding a request indicator to search
|
||||||
[source, html]
|
[source, html]
|
||||||
----
|
----
|
||||||
<input id="search" type="search" name="q" value="{{ request.args.get('q') or '' }}"
|
<input id="search" type="search" name="q" value="{{ request.args.get('q') or '' }}"
|
||||||
@ -499,15 +494,14 @@ Let's add a spinner after our search input:
|
|||||||
We have added the spinner right after the input. This visually co-locates the request indicator with the element
|
We have added the spinner right after the input. This visually co-locates the request indicator with the element
|
||||||
making the request, and makes it easy for a user to see that something is in fact happening.
|
making the request, and makes it easy for a user to see that something is in fact happening.
|
||||||
|
|
||||||
Note that the indicator `img` tag has the `htmx-indicator` class on it. `htmx-indicator` is a CSS class that is
|
It just works, but how does htmx make the spinner appear and disappear? Note that the indicator `img` tag has the `htmx-indicator` class on it. `htmx-indicator` is a CSS class that is
|
||||||
automatically injected into the page by htmx. This class sets the default `opacity` of an element to `0`, which hides
|
automatically injected into the page by htmx. This class sets the default `opacity` of an element to `0`, which hides
|
||||||
the element from view, while at the same time not disrupting the layout of the page.
|
the element from view, while at the same time not disrupting the layout of the page.
|
||||||
|
|
||||||
When an htmx request is triggered that points to this indicator, another class, `htmx-request` is added to the indicator
|
When an htmx request is triggered that points to this indicator, another class, `htmx-request` is added to the indicator
|
||||||
which transitions its opacity to 1. So you can use just about anything as an indicator, and it will be hidden by default,
|
which transitions its opacity to 1. So you can use just about anything as an indicator, and it will be hidden by default. Then, when a request is in flight, it will be shown. This is all done via standard CSS classes, allowing you to control
|
||||||
and then, when a request is in flight, will be shown. This is all done via standard CSS classes, allowing you to control
|
|
||||||
the transitions and even the mechanism by which the indicator is shown (e.g. you might use `display` rather than
|
the transitions and even the mechanism by which the indicator is shown (e.g. you might use `display` rather than
|
||||||
`opacity`). Htmx is flexible in this regard.
|
`opacity`).
|
||||||
|
|
||||||
.Use Request Indicators!
|
.Use Request Indicators!
|
||||||
****
|
****
|
||||||
@ -515,28 +509,26 @@ Request indicators are an important UX aspect of any distributed application. I
|
|||||||
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 significant aspect of your application. Even though requests might seem instant when you are
|
Be sure not to neglect this significant aspect of your application. Requests might seem instant when you are
|
||||||
working on your application locally, in the real world they can take quite a bit longer due to network latency. It's
|
working on your application locally, but in the real world they can take quite a bit longer due to network latency. It's
|
||||||
often a good idea to take advantage of browser developer tools that allow you to throttle your local browser's response
|
often a good idea to take advantage of browser developer tools that allow you to throttle your local browser's response
|
||||||
times. This will give you a better idea of what real world users are seeing, and show you where indicators might help
|
times. This will give you a better idea of what real world users are seeing, and show you where indicators might help
|
||||||
users understand exactly what is going on.
|
users understand exactly what is going on.
|
||||||
****
|
****
|
||||||
|
|
||||||
With this request indicator, we now have a pretty sophisticated user experience built out when compared with plain HTML, but
|
With this request indicator, we now have a pretty sophisticated user experience when compared with plain HTML, but
|
||||||
we've built it all as a hypermedia-driven feature. No JSON or JavaScript to be seen. And this particular implementation also
|
we've built it all as a hypermedia-driven feature. No JSON or JavaScript to be seen. And our implementation has the benefit of being a progressive enhancement; the application will continue to work for clients
|
||||||
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.
|
that don't have JavaScript enabled.
|
||||||
|
|
||||||
== Lazy Loading
|
== Lazy Loading
|
||||||
|
|
||||||
With Active Search behind us, let's move on to a very different sort of problem: lazy loading. Lazy loading is
|
With Active Search behind us, let's move on to a very different sort of enhancement: lazy loading. Lazy loading is
|
||||||
when the loading of a particular bit of content is deferred until later, when needed. This is commonly used as a
|
when the loading of a particular bit of content 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
|
performance enhancement: you avoid the processing resources necessary to produce some data until that data is actually
|
||||||
needed.
|
needed.
|
||||||
|
|
||||||
Let's add a count of the total number of contacts to Contact.app, just below the bottom of our contacts table. This will
|
Let's add a count of the total number of contacts to Contact.app, just 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
|
give us a potentially expensive operation that we can use to demonstrate how to add lazy loading with htmx.
|
||||||
application with htmx.
|
|
||||||
|
|
||||||
First let's update our server code in the `/contacts` request handler to get a count of the total number of contacts.
|
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.
|
We will pass that count through to the template to render some new HTML.
|
||||||
@ -560,11 +552,10 @@ def contacts():
|
|||||||
<1> Get the total count of contacts from the Contact model.
|
<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.
|
<2> Pass the count out to the `index.html` template to use when rendering.
|
||||||
|
|
||||||
As with the rest of the application, in the interest of staying focused on the _hypermedia_ part of Contact.app, we are
|
As with the rest of the application, in the interest of staying focused on the _hypermedia_ part of Contact.app, we'll skip over the details of how `Contact.count()` works. We just need to know that:
|
||||||
not going to look into the details of how `Contact.count()` works. We just need to know that:
|
|
||||||
|
|
||||||
* It returns the total count of contacts in the contact database
|
* It returns the total count of contacts in the contact database.
|
||||||
* It may potentially be slow
|
* It may be slow (for the sake of our example).
|
||||||
|
|
||||||
Next lets add some HTML to our `index.html` that takes advantage of this new bit of data, showing a message next
|
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:
|
to the "Add Contact" link with the total count of users. Here is what our HTML looks like:
|
||||||
@ -590,7 +581,7 @@ image::screenshot_total_contacts.png[(22 total Contacts)]
|
|||||||
Beautiful.
|
Beautiful.
|
||||||
|
|
||||||
Of course, as you probably suspected, all is not perfect. Unfortunately, upon shipping this feature to production, we
|
Of course, as you probably suspected, all is not perfect. Unfortunately, upon shipping this feature to production, we
|
||||||
start getting some complaints from the users that the application "`feels slow.`" Like all good developers faced with
|
start getting complaints from users that the application "`feels slow.`" Like all good developers faced with
|
||||||
a performance issue, rather than guessing what the issue might be, we try to get a performance profile of the application
|
a performance issue, 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.
|
to see what exactly is causing the problem.
|
||||||
|
|
||||||
@ -608,8 +599,7 @@ using htmx instead.
|
|||||||
|
|
||||||
=== Pulling Out The Expensive Code
|
=== Pulling Out The Expensive Code
|
||||||
|
|
||||||
The first step in implementing the Lazy Load pattern is to pull the expensive code, that is, the call to `Contacts.count()`
|
The first step in implementing the Lazy Load pattern is to pull the expensive code -- that is, the call to `Contacts.count()` -- out of the request handler for the `/contacts` endpoint.
|
||||||
out of the request handler for the `/contacts` endpoint.
|
|
||||||
|
|
||||||
Let's put this function call into its own HTTP request handler as a new HTTP endpoint that we will put at `/contacts/count`.
|
Let's put this function call into its own HTTP request handler as a new HTTP endpoint that we will put at `/contacts/count`.
|
||||||
For this new endpoint, we won't need to render a template at all: its sole job is going to be to render that small bit of text
|
For this new endpoint, we won't need to render a template at all: its sole job is going to be to render that small bit of text
|
||||||
@ -676,7 +666,7 @@ to populate it instead.
|
|||||||
|
|
||||||
And, check it out, our `/contacts` page is fast again! When you navigate to the page it feels very snappy and
|
And, check it out, our `/contacts` page is fast again! When you navigate to the page it feels very snappy and
|
||||||
profiling shows that yes, indeed, the page is loading much more quickly. Why is that? Well, we've deferred the
|
profiling shows that yes, indeed, the page is loading much more quickly. Why is that? Well, we've deferred the
|
||||||
expensive calculation to a secondary request, allowing the initial request to finish loading much more quickly.
|
expensive calculation to a secondary request, allowing the initial request to finish loading faster.
|
||||||
|
|
||||||
You might say "`OK, great, but it's still taking a second or two to get the total count on the page.`" True, but
|
You might say "`OK, great, but it's still taking a second or two to get the total count on the page.`" True, but
|
||||||
often the user may not be particularly interested in the total count. They may just want to come to the page and
|
often the user may not be particularly interested in the total count. They may just want to come to the page and
|
||||||
@ -690,7 +680,7 @@ Yes, the total time to get all the information on the screen takes just as long.
|
|||||||
we now need two HTTP requests to get all the information for the page. But the _perceived performance_ for the end user will
|
we now need two HTTP requests to get all the information for the page. But the _perceived 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.
|
be much better: they can do what they want nearly immediately, even if some information isn't available instantaneously.
|
||||||
|
|
||||||
Lazy Loading is a great tool to have in your tool belt when optimizing your web application performance.
|
Lazy Loading is a great tool to have in your belt when optimizing web application performance.
|
||||||
|
|
||||||
=== Adding An Indicator
|
=== Adding An Indicator
|
||||||
|
|
||||||
@ -721,12 +711,11 @@ So let's add that spinner from the active search example as the initial content
|
|||||||
----
|
----
|
||||||
<1> Yep, that's it.
|
<1> Yep, that's it.
|
||||||
|
|
||||||
Now when the user loads the page, rather than having the total contact count sprung on them like a surprise,
|
Now when the user loads the page, rather than having the total contact count magically appear,
|
||||||
there is a nice spinner indicating that something is coming. Much better.
|
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`. Once again
|
Note that all we had to do was copy and paste our indicator from the active search example into the `span`. Once again
|
||||||
we see a great demonstration of how htmx provides flexible, composable features and building blocks for you to
|
we see how htmx provides flexible, composable features and building blocks. Implementing a new feature is often just copy-and-paste, maybe a tweak or two, and you are done.
|
||||||
work with: implementing a new feature is often just copy-and-paste, maybe a tweak or two, and you are done.
|
|
||||||
|
|
||||||
=== But That's Not Lazy!
|
=== But That's Not Lazy!
|
||||||
|
|
||||||
@ -864,7 +853,7 @@ we did in our "`Click To Load`" and "`Infinite Scroll`" features:
|
|||||||
|
|
||||||
=== Updating The Server Side
|
=== Updating The Server Side
|
||||||
|
|
||||||
Now we need to update the server side as well. We want to keep the "`Delete Contact`" button working as well, and in
|
Now we need to update the server side. 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
|
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.
|
triggered by the button and `DELETE` requests that come from this anchor.
|
||||||
|
|
||||||
@ -886,7 +875,7 @@ change to the existing HTML:
|
|||||||
<1> An `id` attribute has been added to the button.
|
<1> An `id` attribute has been added to the button.
|
||||||
|
|
||||||
By giving this button an id attribute, we now have a mechanism for differentiating between the delete button in the
|
By giving this button an id attribute, 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. When this button issues a request, it will now
|
`edit.html` template and the delete links in the `rows.html` template. When this button issues a request, it will
|
||||||
look something like this:
|
look something like this:
|
||||||
|
|
||||||
[source, http]
|
[source, http]
|
||||||
@ -927,13 +916,13 @@ def contacts_delete(contact_id=0):
|
|||||||
|
|
||||||
And that's our server-side implementation: when a user clicks "`Delete`" on a contact row and confirms the delete, the row will
|
And that's our server-side implementation: when a user clicks "`Delete`" on a contact row and confirms the delete, the row will
|
||||||
disappear from the UI. Once again, we have a situation where just changing a few lines of simple code gives us a
|
disappear from the UI. 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 in this manner.
|
dramatically different behavior. Hypermedia is powerful in this manner.
|
||||||
|
|
||||||
=== The Htmx Swapping Model
|
=== 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
|
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 would nice if, rather than just instantly deleting the row, we faded it out before we removed
|
swapping model: it would nice 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
|
it. The fade would make it clear that the row is being removed, giving the user some nice visual feedback on the
|
||||||
deletion.
|
deletion.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@ -961,17 +950,17 @@ CSS transitions are a technology that allow you to animate a transition from one
|
|||||||
you changed the height of something from 10 pixels to 20 pixels, by using a CSS transition you can make the element
|
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
|
smoothly animate to the new height. These sorts of animations are fun, often increase application usability, and are
|
||||||
a great mechanism to add polish to your web application.
|
a great mechanism to add polish to your web application.
|
||||||
|
****
|
||||||
|
|
||||||
Unfortunately, CSS transitions are difficult to access in plain HTML: you usually have to use JavaScript and add or remove classes
|
Unfortunately, CSS transitions are difficult to access in plain HTML: you usually have to use JavaScript and add or remove classes
|
||||||
to get them to trigger. This is why the htmx swap model is more complicated than you might initially think: by swapping
|
to get them to trigger. This is why the htmx swap model is more complicated than you might initially think. By swapping
|
||||||
in classes and adding small delays, you can access CSS transitions purely within HTML, without needing to write any
|
in classes and adding small delays, you can access CSS transitions purely within HTML, without needing to write any
|
||||||
JavaScript!
|
JavaScript!
|
||||||
****
|
|
||||||
|
|
||||||
=== Taking Advantage of "`htmx-swapping`"
|
=== 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
|
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
|
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
|
`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:
|
the row to 0. Here is what that CSS looks like:
|
||||||
|
|
||||||
@ -992,7 +981,7 @@ above makes sense to you, even if this is the first time you've seen CSS transit
|
|||||||
|
|
||||||
So, think about what this means from the htmx swapping model: when htmx gets content back to swap into the row it will
|
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,
|
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.
|
fading the row out. Then the new (empty) content will be swapped in, which will effectively remove 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
|
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
|
short, a few milliseconds. That makes sense in most cases: you don't want to have much of a delay before you put the
|
||||||
@ -1021,7 +1010,7 @@ With this modification, the existing row will stay in the DOM for an additional
|
|||||||
on it. This will give the row time to transition to an opacity of zero, giving the fade out effect we want.
|
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
|
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. Pretty fancy, and all done in a declarative, hypermedia oriented manner, no
|
faded to a 0 opacity, it will be removed. Pretty fancy, and all done in a declarative, hypermedia-oriented manner, no
|
||||||
JavaScript required. (Well, obviously htmx is written in JavaScript, but you know what we mean: we didn't have to write
|
JavaScript required. (Well, obviously htmx is written in JavaScript, but you know what we mean: we didn't have to write
|
||||||
any JavaScript to implement the feature.)
|
any JavaScript to implement the feature.)
|
||||||
|
|
||||||
@ -1125,14 +1114,14 @@ Now, when the button issues a `DELETE`, it will include all the contact ids that
|
|||||||
|
|
||||||
=== The Server Side for Delete Selected Contacts
|
=== The Server Side for Delete Selected Contacts
|
||||||
|
|
||||||
The server-side implementation is going to look an awful lot like our original server-side code for deleting a contact.
|
The server-side implementation is going to look like our original server-side code for deleting a contact.
|
||||||
In fact, once again, we can just copy and paste, and fix a bit of stuff up:
|
In fact, once again, we can just copy and paste, and make a few fixes:
|
||||||
|
|
||||||
* We want to change the URL to `/contacts`
|
* We want to change the URL to `/contacts`.
|
||||||
* We want the handler to get _all_ the ids submitted as `selected_contact_ids` and iterate over each one, deleting the
|
* We want the handler to get _all_ the ids submitted as `selected_contact_ids` and iterate over each one, deleting the
|
||||||
given contact
|
given contact.
|
||||||
|
|
||||||
Those are really the only changes we need to make! Here is what the server-side code looks like:
|
Those are the only changes we need to make! Here is what the server-side code looks like:
|
||||||
|
|
||||||
.The "`delete selected contacts`" button
|
.The "`delete selected contacts`" button
|
||||||
[source, python]
|
[source, python]
|
||||||
@ -1153,10 +1142,9 @@ def contacts_delete_all():
|
|||||||
<4> Delete the given contact with each id.
|
<4> Delete the given contact with each id.
|
||||||
<5> Beyond that, it's the same code as our original delete handler: flash a message and render the `index.html` template.
|
<5> Beyond that, it's the same code as our original delete handler: flash a message and render the `index.html` template.
|
||||||
|
|
||||||
So, as you can see, we just took the original delete logic and slightly modified it to deal with an array of ids, rather
|
So, we took the original delete logic and slightly modified it to deal with an array of ids, rather than a single id.
|
||||||
than a single id.
|
|
||||||
|
|
||||||
Readers with sharp eyes might notice one other small change: we did away with the redirect that was in the original
|
You might notice one other small change: we did away with the redirect that was in the original
|
||||||
delete code. We did so because we are already on the page we want to re-render, so there is no reason
|
delete code. We did so because we are already on the page we want to re-render, so there is no reason
|
||||||
to redirect and have the URL update to something new. We can just re-render the page, and the new list of contacts (sans the
|
to redirect and have the URL update to something new. We can just re-render the page, and the new list of contacts (sans the
|
||||||
contacts that were deleted) will be re-rendered.
|
contacts that were deleted) will be re-rendered.
|
||||||
|
|||||||
@ -299,7 +299,7 @@ which, perhaps inadvertently, tended to encourage this style of JavaScript.
|
|||||||
So, you can see that the notion of Separation of Concerns doesn't always work out as well as promised: our concerns
|
So, you can see that the notion of Separation of Concerns doesn't always work out as well as promised: our concerns
|
||||||
end up intertwined or coupled pretty deeply, even when we separate them into different files.
|
end up intertwined or coupled pretty deeply, even when we separate them into different files.
|
||||||
|
|
||||||
image::images/diagram/separation-of-concerns.svg["Expectation: HTML concern, CSS concern, JS concern. Reality: HTML Co co co CSS nc nc nc JS ern ern ern"]
|
image::diagram/separation-of-concerns.svg["Expectation: HTML concern, CSS concern, JS concern. Reality: HTML Co co co CSS nc nc nc JS ern ern ern"]
|
||||||
|
|
||||||
To show that it isn't just naming between concerns that can get you into trouble, consider another small change to our HTML
|
To show that it isn't just naming between concerns that can get you into trouble, consider another small change to our HTML
|
||||||
that demonstrates the problems with our separation of concerns: imagine that we decide to change the number field from
|
that demonstrates the problems with our separation of concerns: imagine that we decide to change the number field from
|
||||||
@ -1288,7 +1288,7 @@ ____
|
|||||||
In case of conflict, consider users over authors over implementors over specifiers over theoretical purity.
|
In case of conflict, consider users over authors over implementors over specifiers over theoretical purity.
|
||||||
____
|
____
|
||||||
|
|
||||||
We have shown you quite a few tools and techniques for scripting in a Hypermedia-Driven Application. How should you
|
We have looked at several tools and techniques for scripting in a Hypermedia-Driven Application. How should you
|
||||||
pick between them? The sad truth is that there will never be a single, always correct answer to this question.
|
pick between them? The sad truth is that there will never be a single, always correct answer to this question.
|
||||||
|
|
||||||
Are you committed to vanilla JavaScript-only, perhaps due to company policy? Well, you can use vanilla JavaScript effectively
|
Are you committed to vanilla JavaScript-only, perhaps due to company policy? Well, you can use vanilla JavaScript effectively
|
||||||
@ -1307,9 +1307,9 @@ In general, we encourage a _pragmatic_ approach to scripting: whatever feels rig
|
|||||||
right _enough_) for you. Rather than being concerned about which particular approach is taken for your scripting,
|
right _enough_) for you. Rather than being concerned about which particular approach is taken for your scripting,
|
||||||
we would focus on these more general concerns:
|
we would focus on these more general concerns:
|
||||||
|
|
||||||
* Avoid communicating with the server via JSON data APIs
|
* Avoid communicating with the server via JSON data APIs.
|
||||||
* Avoid storing large amounts of state outside of the DOM
|
* Avoid storing large amounts of state outside of the DOM.
|
||||||
* Favor using events, rather than hard-coded callbacks or method calls
|
* Favor using events, rather than hard-coded callbacks or method calls.
|
||||||
|
|
||||||
And even on these topics, sometimes a web developer has to do what a web developer has to do. If the perfect widget
|
And even on these topics, sometimes a web developer has to do what a web developer has to do. If the perfect widget
|
||||||
for your application exists but uses a JSON data API? That's OK.
|
for your application exists but uses a JSON data API? That's OK.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user