mirror of
https://github.com/bigskysoftware/hypermedia-systems.git
synced 2025-11-29 00:05:26 -05:00
CH10 and some small updates elsewhere
This commit is contained in:
parent
0e4ec41d5a
commit
eb0271b6dd
16
images/figure_1-1_http_mental_model.svg
Normal file
16
images/figure_1-1_http_mental_model.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 46 KiB |
BIN
images/screenshot_progress_bar.png
Normal file
BIN
images/screenshot_progress_bar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@ -120,7 +120,7 @@ Here is a visual representation of these two hypermedia interactions:
|
||||
|
||||
[#figure-1-1, reftext="Figure {chapter}.{counter:figure}"]
|
||||
.HTTP Requests In Action
|
||||
image::../images/figure_1-1_http_mental_model.png[]
|
||||
image::../images/figure_1-1_http_mental_model.svg[]
|
||||
|
||||
As someone interested in web development, the above diagram should look very familiar to you, perhaps even boring. But,
|
||||
despite its familiarity, consider the fact that the two above mechanisms are really the _only_ native ways for a user
|
||||
@ -311,7 +311,7 @@ These _hypermedia-oriented_ libraries re-center the hypermedia approach as a via
|
||||
In the web development world today there is a debate going on between the SPAs approach and what are now being called
|
||||
"Multi-Page Applications" or MPAs. MPAs are usually just the old, traditional way of building web applications with
|
||||
links and forms across multiple web pages and are thus, by their nature, hypermedia oriented. They are clunky, but,
|
||||
despite this clunkiness, some web developers have become so exasperated at the complexity of SPA applications they have
|
||||
despite this clunkiness, some web developers have become so exasperated at the complexity of SPA applications they `have`
|
||||
decided to go back to this older way of building things and just accept the limitations of plain HTML.
|
||||
|
||||
Some thought leaders in web development, such as Rich Harris, creator of svelte.js, a popular SPA library, propose a mix
|
||||
@ -320,7 +320,7 @@ it attempts to mix both the older MPA approach and the newer SPA approach into a
|
||||
like the "transitional" trend in architecture, which blends traditional and modern architectural styles. It's a
|
||||
good term and a reasonable compromise between the two approaches to building web applications.
|
||||
|
||||
But it still feels a bit unsatisfactory. Why have two very different architectual models _by default_? Recall that the
|
||||
But it still feels a bit unsatisfactory. Why have two very different architectural models _by default_? Recall that the
|
||||
crux of the tradeoffs between SPAs and MPAs is the _user experience_ or interactivity of the application.
|
||||
This is typically the driving decision when choosing one approach versus the other for an application or, in the case
|
||||
of Transitional Web Applications, for a particular feature.
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
= Hypermedia In Action
|
||||
:chapter: 8
|
||||
:chapter: 9
|
||||
:sectnums:
|
||||
:figure-caption: Figure {chapter}.
|
||||
:listing-caption: Listing {chapter}.
|
||||
:table-caption: Table {chapter}.
|
||||
:sectnumoffset: 7
|
||||
:sectnumoffset: 8
|
||||
// line above: :sectnumoffset: 5 (chapter# minus 1)
|
||||
:leveloffset: 1
|
||||
:sourcedir: ../code/src
|
||||
@ -245,7 +245,6 @@ like this:
|
||||
"phone": ""
|
||||
},
|
||||
...
|
||||
// TODO how to indicate code ommitted
|
||||
----
|
||||
|
||||
So, you can see, a relatively simple JSON representation of our contacts. Not perfect, but good enough for the purposes
|
||||
@ -484,5 +483,9 @@ still evolving separately is not as difficult or as crazy as it might sound at f
|
||||
|
||||
== Summary
|
||||
|
||||
* Having a Hypermedia Driven API is not mutually exclusive with having a JSON Data API
|
||||
applications
|
||||
* In this chapter we saw that a Hypermedia Driven Application can have a JSON Data API as well
|
||||
* Hypermedia APIs and JSON Data APIs have different needs and shapes
|
||||
* By properly factoring your back end code, much of the logic can be shared between the two APIs
|
||||
* By splitting your APIs into both a Hypermedia API and a JSON Data API, you can evolve both without interferring with
|
||||
one another, allowing you to, for example, change your Hypermedia API dramatically without breaking your JSON API
|
||||
clients
|
||||
589
manuscript/CH10_gross_download_ui.adoc
Normal file
589
manuscript/CH10_gross_download_ui.adoc
Normal file
@ -0,0 +1,589 @@
|
||||
= Hypermedia In Action
|
||||
:chapter: 8
|
||||
:sectnums:
|
||||
:figure-caption: Figure {chapter}.
|
||||
:listing-caption: Listing {chapter}.
|
||||
:table-caption: Table {chapter}.
|
||||
:sectnumoffset: 7
|
||||
// line above: :sectnumoffset: 5 (chapter# minus 1)
|
||||
:leveloffset: 1
|
||||
:sourcedir: ../code/src
|
||||
:source-language:
|
||||
|
||||
= Creating A Dynamic Archive UI
|
||||
|
||||
This chapter covers
|
||||
|
||||
* Creating a dynamically updated download UI using hypermedia
|
||||
* Adding smooth animations to a progress bar
|
||||
* Triggering a file download with a response header
|
||||
|
||||
[partintro]
|
||||
== A Dynamic Archive UI
|
||||
|
||||
We've come a long way from our plain old traditional web application at this point: we've added active search, bulk
|
||||
delete (with some nice animations) and a slew of other features, to say nothing of the hyperview-based mobile application
|
||||
we have built. I hope you'll agree that we have reached a level of interactivity that most people would assume requires
|
||||
some sort JavaScript framework, but we've done nearly all of it with good old hypermedia and a bit of scripting on the
|
||||
side.
|
||||
|
||||
However, despite the wonderful reception of Contact.app in the world, one feature keeps coming up again and again: users
|
||||
would like to be able to download all their contacts, preferably in an easy-to-use JSON format.
|
||||
|
||||
This is a reasonable request and another team has been working on the back-end support for doing exactly this. There is
|
||||
one problem though: the archive takes a bit of time to prepare and export, typically on the order of five to 10 seconds,
|
||||
but sometimes longer.
|
||||
|
||||
This is a classic problem in web app development. When faced with a long-running process we have two options:
|
||||
|
||||
* When the user triggers the action, block until it is complete and then respond with the result
|
||||
* Start the action and return immediately
|
||||
|
||||
Just blocking and waiting for the action to complete is certainly the easy way to handle it, but it is a pretty terrible
|
||||
user experience. If you've ever clicked on something in a web 1.0-style application and then had to sit there for
|
||||
what seems like an eternity before anything happens, you've seen the results of this choice.
|
||||
|
||||
The second option, starting the action in a separate, asynchronous manner (say, by starting a thread, or submitting it
|
||||
to a job runner system) is much nicer: you can respond immediately and the user doesn't need to sit there wondering what's
|
||||
going on. But the question is, what do you respond with?
|
||||
|
||||
I have seen a few different "simple" approaches in this scenario:
|
||||
|
||||
* Let the user know that the process has started and that they will be emailed a link to the completed process
|
||||
results when it is finished
|
||||
* Let the user know that the process has started and recommend that they manually (!!!) refresh the page to see the
|
||||
status of the process
|
||||
* Let the user know that the process has started and, using some JavaScript, automatically refresh the page every few
|
||||
seconds
|
||||
|
||||
All of these work, but they sure aren't great user experiences, are they?
|
||||
|
||||
What we'd like in this scenario is something more like what you see when, for example, you download a file via the
|
||||
browser: a nice progress bar indicating where in the process you are and then an option to click a link immediately
|
||||
to view the result of the process.
|
||||
|
||||
Now, at this point, surely we are beyond what can be achieved using only hypermedia, right? Well, we wouldn't have a
|
||||
whole chapter on this topic if that were the case, would we? We'll need to push htmx pretty hard to make this all work,
|
||||
but when it is done it won't be _that_ much code, and it will give us the user interface that we want.
|
||||
|
||||
=== UI Requirements
|
||||
|
||||
Before we dive into the implementation, let's discuss in broad terms what our new UI should look like: we want a button
|
||||
in the application labeled "Download Contact Archive". When a user clicks on that button, we want to replace that
|
||||
button with a progress bar instead. As the archive job progresses, we want to move the progress bar along. When the
|
||||
archive job is done, we want to show a link to the user to download the archive file.
|
||||
|
||||
As I mentioned earlier, thankfully another team has been working on the actual archive process, and they have given us
|
||||
a class that we can work with, `Archiver`, that implements all the functionality that we need. In particular,
|
||||
it gives us the following methods:
|
||||
|
||||
* `status()` - A string representing the status of the download, either `Waiting`, `Running` or `Complete`
|
||||
* `progress()` - A number between 0 and 1, indicating how much progress the archive job has made
|
||||
* `run()` - Starts A new archive job (if the current status is `Waiting`)
|
||||
* `reset()` - Cancels the current archive job, if any, and resets to the "Waiting" state
|
||||
* `archive_file()` - The path to the archive file that has been created on the server, so we can send it to the client
|
||||
* `get()` - A class method that lets us get the Archiver for the current user
|
||||
|
||||
Not a terribly complicated API, the only somewhat tricky aspect to it is that the `run()` method is non-blocking: it
|
||||
starts a background job to do the actual archiving and returns immediately.
|
||||
|
||||
=== Beginning Our Implementation
|
||||
|
||||
Now we have everything we need to begin implementing our UI: a reasonable outline of what it is going to look like, and
|
||||
the domain logic to support it.
|
||||
|
||||
So, in getting down to building the UI, the first thing I want to note is that the UI is largely self-contained: we
|
||||
want to replace the button with the download progress bar, and then the link to download the results of the archive
|
||||
process. Everything will all be in one place in the UI, which is a strong hint that we want to create a new template
|
||||
to handle this little sub-section of the application. Let's call this template `archive_ui.html`.
|
||||
|
||||
Another thing that jumps out at me is that we are going to want to replace the entire download UI in multiple cases.
|
||||
Since we want to do that, it makes sense to wrap the entire UI in a `div` tag, and then use that `div` as the target
|
||||
for all our operations. So let's get our new template going with the following content:
|
||||
|
||||
.Our Initial Archive UI Template
|
||||
[source, html]
|
||||
----
|
||||
<div id="archive-ui" hx-target="this"<1> hx-swap="outerHTML"<2>>
|
||||
|
||||
</div>
|
||||
----
|
||||
<1> This div will be the target for all elements inside of it
|
||||
<2> Replace the entire div every time using `outerHTML`
|
||||
|
||||
Next, lets add that "Download Contact Archive" button to the `div`, which will kick off the archive-then-download
|
||||
process. Let's use a `POST` to the path `/contacts/archive` to trigger the start of the process:
|
||||
|
||||
.Adding The Button
|
||||
[source, html]
|
||||
----
|
||||
<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
|
||||
<button hx-post="/contacts/archive"> <1>
|
||||
Download Contact Archive
|
||||
</button>
|
||||
</div>
|
||||
----
|
||||
<1> This button will issue a `POST` to `/contacts/archive`
|
||||
|
||||
Finally, let's include this template in our main `index.html` template, above the contacts table:
|
||||
|
||||
.Our Initial Archive UI Template
|
||||
[source, html]
|
||||
----
|
||||
{% block content %}
|
||||
|
||||
{% include 'archive_ui.html' %} <1>
|
||||
|
||||
<form action="/contacts" method="get" class="tool-bar">
|
||||
----
|
||||
<1> This template will now be included in the main template
|
||||
|
||||
With that done, we now have a button showing up in our web application to get the download going. Since the enclosing
|
||||
`div` has an `hx-target="this"` on it, the button will inherit that target and replace the `div` with whatever HTML
|
||||
comes back from the `POST` to `/contacts/archive`.
|
||||
|
||||
=== Adding the POST End Point
|
||||
|
||||
Our next step is to handle the `POST` that the button is making. What we are going to want to do is to get the
|
||||
`Archiver` for the current user and invoke the `run()` method on it. This will start the archive process running. Then
|
||||
we will want to render some new content indicating that the process is running.
|
||||
|
||||
To do that, what we want to do is reuse the `archive_ui` template to handle rendering the archive UI for both states,
|
||||
when the archiver is "Waiting" and when it is "Running". (We will also handle the "Complete" state in a bit.)
|
||||
|
||||
This is a very common pattern: we put all the different UIs for a given conceptual "chuck" of the user interface into
|
||||
a single template, and conditionally render the appropriate interface. This keeps everything together and makes it
|
||||
very easy to understand how the UIs interact with one another.
|
||||
|
||||
Since we are going to conditionally render different user interfaces based on the state of the archiver, we will need
|
||||
to pass the archiver out to the template. So, again: we need to invoke `run()` and then pass the archiver out to the
|
||||
template for conditional rendering. Here is what the code looks like:
|
||||
|
||||
.Server Side Code To Start The Archive Process
|
||||
[source, python]
|
||||
----
|
||||
@app.route("/contacts/archive", methods=["POST"]) <1>
|
||||
def start_archive():
|
||||
archiver = Archiver.get() <2>
|
||||
archiver.run() <3>
|
||||
return render_template("archive_ui.html", archiver=archiver) <4>
|
||||
----
|
||||
<1> Handle `POST` to `/contacts/archive`
|
||||
<2> Look up the Archiver
|
||||
<3> Invoke the non-blocking `run()` method on it
|
||||
<4> Render the `archive_ui.html` template, passing in the archiver
|
||||
|
||||
=== Conditionally Rendering A Progress UI
|
||||
|
||||
Now let's turn our attention to updating `archive_ui.html` to conditionally. We are passing the archiver through
|
||||
as a variable to the template, and recall that the archiver has a `status()` method that we can consult to see what
|
||||
the status of the archive process.
|
||||
|
||||
We want to render the "Download Contact Archive" button if the archiver has the status `Waiting`, and we want to render
|
||||
some sort of message indicating that progress is happening if the status is `Running`. Let's update our template code
|
||||
to do just that:
|
||||
|
||||
.Adding Conditional Rendering
|
||||
[source, html]
|
||||
----
|
||||
<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
|
||||
{% if archiver.status() == "Waiting" %} <1>
|
||||
<button hx-post="/contacts/archive">
|
||||
Download Contact Archive
|
||||
</button>
|
||||
{% elif archiver.status() == "Running" %}<2>
|
||||
Running...<3>
|
||||
{% end %}
|
||||
|
||||
</div>
|
||||
----
|
||||
<1> Only render button if the status is "Waiting"
|
||||
<2> Render different content when status is "Running"
|
||||
<3> For now, just some text saying things are Running
|
||||
|
||||
OK, great, we have some conditional logic in our template view, and the server side logic to support kicking off the
|
||||
archive process. We don't have a progress bar yet, but we'll get there! Let's see how this works as it stands, and
|
||||
refresh the main page of our application...
|
||||
|
||||
Ouch:
|
||||
|
||||
.Something Went Wrong
|
||||
----
|
||||
UndefinedError
|
||||
jinja2.exceptions.UndefinedError: 'archiver' is undefined
|
||||
----
|
||||
|
||||
We get an error message right out of the box. Why? Ah, of course, we are including the `archive_ui.html` in the
|
||||
`index.html` template, but now the `archive_ui.html` template expects the archiver to be passed through to it, so
|
||||
it can conditionally render the correct UI. Well, that's an easy fix: we just need to pass the archiver through
|
||||
when we render the `index.html` template as well:
|
||||
|
||||
.Including The Archiver When We Render index.html
|
||||
[source, python]
|
||||
----
|
||||
@app.route("/contacts")
|
||||
def contacts():
|
||||
search = request.args.get("q")
|
||||
if search:
|
||||
contacts_set = Contact.search(search)
|
||||
if request.headers.get('HX-Trigger') == 'search':
|
||||
return render_template("rows.html", contacts=contacts_set)
|
||||
else:
|
||||
contacts_set = Contact.all()
|
||||
return render_template("index.html", contacts=contacts_set, archiver=Archiver.get())<1>
|
||||
----
|
||||
<1> Pass through archiver to the main template
|
||||
|
||||
Now with that done, we can load up the page. And, sure enough, we can see the "Download Contact Archive" button now!
|
||||
When we click on it, the button is replaced with the content "Running...", and we can see in our development console
|
||||
on the server side that the job is indeed getting kicked off properly.
|
||||
|
||||
== Polling
|
||||
|
||||
That's definitely progress, but we don't exactly have the best progress indicator here: just some static text telling
|
||||
the user that the process is running!
|
||||
|
||||
What we want to do is have the content update as the process makes progress and, ideally, show a progress bar indicating
|
||||
how far along it is. How can we do that in htmx using plain old hypermedia?
|
||||
|
||||
The technique we want to use here is called "polling", where we issue a request on an interval and update the UI based
|
||||
on the new state of the server. Polling has a bit of a bad rap, and it isn't the sexiest technique in the world: today
|
||||
developers might look at a more advanced technique like WebSockets or Server Sent Events (SSE) to address this situation.
|
||||
But, say what one will, polling _works_ and it is drop-dead simple. You need to be careful to make sure you don't overwhelm
|
||||
you system with polling requests, but, with a bit of care, you can create a reliable, passively updated component in
|
||||
your UI.
|
||||
|
||||
htmx offers two types of polling. The first is "fixed rate polling", which uses a special `hx-trigger` syntax to indicate
|
||||
that something should be polled on a fixed interval. Here is an example:
|
||||
|
||||
.Fixed Interval Polling
|
||||
[source, html]
|
||||
----
|
||||
<div hx-get="/messages" hx-trigger="every 3s"> <1>
|
||||
</div>
|
||||
----
|
||||
<1> trigger a `GET` to `/messages` every three seconds
|
||||
|
||||
This works great in situations when you want to poll indefinitely, for example if you want to constantly poll for new
|
||||
messages to display to the user. However, fixed rate polling isn't ideal when you have a definite process after which
|
||||
you want to stop polling: it keeps polling forever, until the element it is on is removed from the DOM.
|
||||
|
||||
In our case, we have a definite process with an ending to it. So, in our case, it will be better to use the other polling
|
||||
technique, known as "load polling". In load polling, you take advantage of the fact that htmx triggers a `load` event
|
||||
when content is loaded into the DOM. So you can create a trigger on the `load` event, but then add a bit of a delay so that
|
||||
the request doesn't trigger immediately.
|
||||
|
||||
If you do this, then you can conditionally render the `hx-trigger` on every request: when a process has completed you
|
||||
can simply not include the trigger and the load polling stops. A nice and simple way to poll for until a definite
|
||||
process finishes.
|
||||
|
||||
=== Using Polling To Update The Archive UI
|
||||
|
||||
So, let's use load polling now to update our UI as the archiver makes progress. To show the progress, let's use
|
||||
a CSS-based progress bar, taking advantage of the `progress()` method which returns a number between 0 and 1 indicating
|
||||
how close the archive process is to completion. Here is the snippet of HTML we will use:
|
||||
|
||||
.A CSS-based Progress Bar
|
||||
[source, html]
|
||||
----
|
||||
<div class="progress" >
|
||||
<div class="progress-bar" style="width:{{ archiver.progress() * 100 }}%"></div> <1>
|
||||
</div>
|
||||
----
|
||||
<1> The width of the inner element corresponds to the progress
|
||||
|
||||
This CSS-based progress bar has two components: an outer `div` that provides the wire frame for the progress bar, and
|
||||
and inner `div` that is the actual progress bar indicator. We set the width of the inner progress bar to some percentage
|
||||
(note we need to multiply the `progress()` result by 100 to get a percentage) and that will make the progress
|
||||
indicator the appropriate width within the parent div.
|
||||
|
||||
As I have mentioned before, this is not a book on CSS, but, for completeness, here is the CSS for this progress bar:
|
||||
|
||||
.The CSS For Our Progress Bar
|
||||
[source, css]
|
||||
----
|
||||
.progress {
|
||||
height: 20px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
float: left;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background-color: #337ab7;
|
||||
box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
|
||||
transition: width .6s ease;
|
||||
}
|
||||
----
|
||||
|
||||
Which ends up rendering like this:
|
||||
|
||||
[#figure-8-1, reftext="Figure {chapter}.{counter:figure}"]
|
||||
.Our CSS-Based Progress Bar
|
||||
image::../images/screenshot_progress_bar.png[]
|
||||
|
||||
So let's add the code for our progress bar into our `archive_ui.html` template for the case when the archiver is
|
||||
running, and let's update the copy to say "Creating Archive...":
|
||||
|
||||
.Adding The Progress Bar
|
||||
[source, html]
|
||||
----
|
||||
<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
|
||||
{% if archiver.status() == "Waiting" %}
|
||||
<button hx-post="/contacts/archive">
|
||||
Download Contact Archive
|
||||
</button>
|
||||
{% elif archiver.status() == "Running" %}
|
||||
<div>
|
||||
Creating Archive...
|
||||
<div class="progress" > <1>
|
||||
<div class="progress-bar" style="width:{{ archiver.progress() * 100 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
----
|
||||
<1> Our shiny new progress bar
|
||||
|
||||
Sweet, now when we click the "Download Contact Archive" button, we get the progress bar. But it still doesn't update
|
||||
because we haven't implemented load polling yet! It just sits there, at zero.
|
||||
|
||||
To get the UI we want, we'll need to implement load polling using `hx-trigger`. We can add this to pretty much
|
||||
any element inside the conditional block for when the archiver is running, so let's add it to that `div` that is
|
||||
wrapping around the "Creating Archive..." text and the progress bar. Finally, let's make it poll by issuing a
|
||||
`GET` to the same path that the `POST` was issued too: `/contacts/archive`. (As you have probably notices, this is a
|
||||
common pattern in RESTful systems: reusing the same path with different actions.)
|
||||
|
||||
.Implementing Load Polling
|
||||
[source, html]
|
||||
----
|
||||
<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
|
||||
{% if archiver.status() == "Waiting" %}
|
||||
<button hx-post="/contacts/archive">
|
||||
Download Contact Archive
|
||||
</button>
|
||||
{% elif archiver.status() == "Running" %}
|
||||
<div hx-get="/contacts/archive" hx-trigger="load delay:500ms"> <1>
|
||||
Creating Archive...
|
||||
<div class="progress" >
|
||||
<div class="progress-bar" style="width:{{ archiver.progress() * 100 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
----
|
||||
<1> Issue a `GET` to `/contacts/archive` 500 milliseconds after the content loads
|
||||
|
||||
Again, it is important to realize that, when this `GET` is issued to `/contacts/archive`, it is going to replace
|
||||
the `div` with the id `archive-ui`, not just itself. The `hx-target` attribute is _inherited_ by all child elements
|
||||
within the `archive-ui` `div`, so, unless it is explicitly overriden by a child, the childrean will all target that
|
||||
outermost `div` in the `archive_ui.html` file.
|
||||
|
||||
OK, now we need to handle the `GET` to `/contacts/archive` on the server. Thankfully, this is quite easy: all we
|
||||
want to do is re-render `archive_ui.html` with the archiver:
|
||||
|
||||
.Handling Progress Updates
|
||||
[source, python]
|
||||
----
|
||||
@app.route("/contacts/archive", methods=["GET"]) <1>
|
||||
def archive_status():
|
||||
archiver = Archiver.get()
|
||||
return render_template("archive_ui.html", archiver=archiver) <2>
|
||||
----
|
||||
<1> handle `GET` to the `/contacts/archive` path
|
||||
<2> just re-render the `archive_ui.html` template
|
||||
|
||||
Simple, like so much else with hypermedia!
|
||||
|
||||
And now, when we click the "Download Contact Archive", sure enough, we get a progress bar that updates every 500
|
||||
milliseconds! And, as the result of the call to `archiver.progress()` incrementally updates from 0 to 1, the
|
||||
progress bar moves across the screen for us, very cool!
|
||||
|
||||
=== Downloading The Result
|
||||
|
||||
OK, we have one more state to handle, the case when `achiver.status()` is set to "Complete", and there is a JSON
|
||||
archive of the data ready to download. When the archiver is complete, we can get the local JSON file on the server
|
||||
from the archiver via the `archive_file()` call.
|
||||
|
||||
Let's add another case to our if statement to handle the "Complete" state, and, when the archive job is complete, lets
|
||||
render a link to a new path, `/contacts/archive/file`, which will respond with the archived JSON file. Here is
|
||||
the new code:
|
||||
|
||||
.Rendering A Download Link When Archiving Completes
|
||||
[source, html]
|
||||
----
|
||||
<div id="archive-ui" hx-target="this" hx-swap="outerHTML">
|
||||
{% if archiver.status() == "Waiting" %}
|
||||
<button hx-post="/contacts/archive">
|
||||
Download Contact Archive
|
||||
</button>
|
||||
{% elif archiver.status() == "Running" %}
|
||||
<div hx-get="/contacts/archive" hx-trigger="load delay:500ms">
|
||||
Creating Archive...
|
||||
<div class="progress" >
|
||||
<div class="progress-bar" style="width:{{ archiver.progress() * 100 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif archiver.status() == "Complete" %} <1>
|
||||
<a hx-boost="false" href="/contacts/archive/file">Archive Ready! Click here to download. ↓</a> <2>
|
||||
{% endif %}
|
||||
</div>
|
||||
----
|
||||
<1> If the status is "Complete", render a download link
|
||||
<2> The link will issue a `GET` to `/contacts/archive/file`
|
||||
|
||||
Note that the link has a `hx-boost` set to `false`. It has this so that the link will not inherit the boost behavior
|
||||
that is present for other links and, thus, will not be issued via AJAX. We want this "normal" link behavior because an
|
||||
AJAX request cannot download a file directly, whereas a plain anchor tag can.
|
||||
|
||||
The final step is to handle the `GET` request to `/contacts/archive/file`. We want to send the file that the
|
||||
archiver created down to the client. We are in luck: flask has a very simple mechanism for sending a file as
|
||||
a downloaded response: the `send_file()` method. We can pass this method the path to the archive file that the archiver
|
||||
created, the name of the file that we want the browser to create, and if we want it sent "as an attachment", which will
|
||||
set the appropriate HTTP response headers to trigger the browsers downloading behavior.
|
||||
|
||||
.Sending A File To The Client
|
||||
[source, python]
|
||||
----
|
||||
@app.route("/contacts/archive/file", methods=["GET"])
|
||||
def archive_content():
|
||||
manager = Archiver.get()
|
||||
return send_file(manager.archive_file(), "archive.json", as_attachment=True) <1>
|
||||
----
|
||||
<1> send the file to the client
|
||||
|
||||
Perfect! Now we have an archive UI that is pretty darned slick: You can click the button and a progress bar appears. When
|
||||
the progress bar reaches 100%, it disappears and a link to download the archive file appears. The user can then
|
||||
click on that link and download their archive!
|
||||
|
||||
== Smoothing Things Out: More On The htmx Swap Model
|
||||
|
||||
As cool as this UI is, there is one minor annoyance with it: as the progress bar updates it "jumps" from one position
|
||||
to the next. This looks jerky and is reminiscent of the feel of a full page refresh in web 1.0 style applications. It
|
||||
turns out that there is a native HTML technology for smoothing out changes on an element from one state to another
|
||||
that we discussed in Chapter 5: the CSS Transitions API.
|
||||
|
||||
Using CSS Transitions, you can smoothly animate an element between different styling by using the `transition` property.
|
||||
|
||||
If you look back at our CSS definition of the `.progress-bar` class, you will see the following transition definition
|
||||
in it: `transition: width .6s ease;`. This means that when the width of the progress bar is changed from, say 20% to
|
||||
30%, the browser will animate over a period of .6 seconds using the "ease" function (which has a nice accelerate/decelerate
|
||||
effect).
|
||||
|
||||
That's great and all, but in our example, htmx is _replacing_ the content with new content. It isn't updating the width
|
||||
of the _existing_ element, which would trigger a transition. Rather, it is simply replacing it with a new element. So
|
||||
no transition will occur, which is, indeed, what we are seeing: the progress bar jumps from spot to spot as it moves
|
||||
towards completion.
|
||||
|
||||
=== Settling
|
||||
|
||||
When we discussed the htmx swap model in Chapter 5, we focused on the classes that htmx adds and removes, but we skipped
|
||||
over the idea of "settling". What is "settling" in htmx terms? Settling is the following process: when htmx is
|
||||
about to replace a chunk of content, it looks through the new content and finds all elements with an `id` on it. It then
|
||||
looks in the _existing_ content for elements with the same `id`. If there is one, it does the following shuffle:
|
||||
|
||||
* The _new_ content gets the attributes of the _old_ content temporarily
|
||||
* The new content is inserted
|
||||
* After a small delay, the new content has its attributes reverted to their actual values
|
||||
|
||||
So, what is this strange little dance supposed to achieve? Well, what this ends up meaning is that, if an element
|
||||
has a stable id between swaps, you _can_ write CSS transitions between various states. Since the new content briefly
|
||||
has the _old_ attributes, the normal CSS mechanism will kick in when the actual values are restored.
|
||||
|
||||
So, in our case, all we need to do is to add a stable ID to our `progress-bar` element, and, rather than jumping
|
||||
on every update, it the progress bar should smoothly move across the screen as it is updating, using the CSS transition
|
||||
defined in our style sheet:
|
||||
|
||||
.Smoothing Things Out
|
||||
[source, html]
|
||||
----
|
||||
<div class="progress" >
|
||||
<div id="archive-progress" class="progress-bar" style="width:{{ archiver.progress() * 100 }}%"></div> <1>
|
||||
</div>
|
||||
----
|
||||
<1> The progress bar div now has a stable id across requests
|
||||
|
||||
All we had to do was add a simple `id` attribute and viola, a much smoother user experience!
|
||||
|
||||
== Dismissing The Download UI
|
||||
|
||||
Next, let's make it possible for the user to dismiss the download link and return to the original export UI state. To
|
||||
do this, we'll add a button that issues a `DELETE` to the path `/contacts/archive`, indicating that the current archive
|
||||
can be removed or cleaned up.
|
||||
|
||||
We'll add it after the download link, like so:
|
||||
|
||||
.Clearing The Download
|
||||
[source, html]
|
||||
----
|
||||
<a hx-boost="false" href="/contacts/archive/file" _="on load click() me">Archive Ready! Click here to download. ↓</a>
|
||||
<button hx-delete="/contacts/archive">Clear Download</button> <1>
|
||||
----
|
||||
<1> A simple button that issues a `DELETE` to `/contacts/archive`
|
||||
|
||||
Now the user has a button that they can click on to dismiss the archive download link. But we will need to hook it up
|
||||
on the server side. As usual, that is straight fowards: we simply create a new handler for the `DELETE` HTTP Action,
|
||||
invoke the `reset()` method on the archiver, and re-render the `archive_ui.html` template. Since this button is
|
||||
picking up the same `hx-target` and `hx-swap` configuration as everything else, it "just works".
|
||||
|
||||
Here is the server side code:
|
||||
|
||||
.Resetting The Download
|
||||
[source, python]
|
||||
----
|
||||
@app.route("/contacts/archive", methods=["DELETE"])
|
||||
def reset_archive():
|
||||
archiver = Archiver.get()
|
||||
archiver.reset() <1>
|
||||
return render_template("archive_ui.html", archiver=archiver)
|
||||
----
|
||||
<1> Call `reset()` on the archiver
|
||||
|
||||
Looks pretty similar to our other methods, doesn't it? That's the idea!
|
||||
|
||||
== Auto-Download
|
||||
|
||||
One pattern that I see sometimes on the web is "auto-downloading" where a file is created and then, when it is ready,
|
||||
the system automatically downloads the file. We can add that functionality quite easily to our application with a bit
|
||||
of hyperscript.
|
||||
|
||||
What we want to do is, when the download link renders, automatically click on the link for the user. The hyperscript
|
||||
will read basically just like that:
|
||||
|
||||
.Auto-Downloading
|
||||
[source, html]
|
||||
----
|
||||
<a hx-boost="false" href="/contacts/archive/file"
|
||||
_="on load click() me"> <1>
|
||||
Archive Downloading! Click here if the download does not start.
|
||||
</a>
|
||||
----
|
||||
<1> a bit of hyperscript to make the file auto-download
|
||||
|
||||
Note that the scripting here is simply _enhancing_ the existing hypermedia, rather than replacing it with a non-hypermedia
|
||||
request. This is hypermedia-friendly scripting!
|
||||
|
||||
So, despite our initial trepidation that it could be done, we've managed to create a very dynamic UI for our archive
|
||||
functionality, with a progress bar and auto-downloading, and we've done nearly all of it (with the exception of a small
|
||||
bit of scripting for auto-download) in pure hypermedia. And it only took about 16 lines of front end code and 16 lines
|
||||
of backend code to build the whole thing, showing once again that HTML, with the help of htmx, can, in fact, be very
|
||||
expressive.
|
||||
|
||||
== Summary
|
||||
|
||||
* In this chapter we built a sophisticated user interface to interact with a non-blocking, asynchornous back end process:
|
||||
creating an archive of all contacts in our application
|
||||
* We saw a few different ways to do polling in htmx, and settled on using "load polling" for our situation
|
||||
* We saw how the htmx swap mechanism enables CSS transitions when an element has a stable ID in new pieces of content,
|
||||
and we used that to smooth out the progress bar in our application
|
||||
* We used a bit of hypermedia-friendly scripting to trigger an auto-download when the archive progress completes
|
||||
|
||||
@ -82,16 +82,23 @@
|
||||
.. 9.3 Adding a JSON Data API To Contact.app
|
||||
.. 9.4 Summary
|
||||
|
||||
. 10 Advanced htmx
|
||||
. 10 Creating A Dynamic Download UI
|
||||
[none]
|
||||
.. 10.1 Configuration
|
||||
.. 10.2 Animations
|
||||
.. 10.3 Debugging
|
||||
.. 10.4 Security Concerns
|
||||
.. 10.5 Understanding the htmx event model
|
||||
.. 10.6 Understanding the extension model
|
||||
.. 10.1 A Dynamic Archive UI
|
||||
.. 10.2 Polling
|
||||
.. 10.3 Smoothing Things Out: More On The htmx Swap Model
|
||||
.. 10.4 Dismissing The Download UI
|
||||
.. 10.5 Auto-Download
|
||||
.. 10.6 Summary
|
||||
|
||||
. 11 Other Hypermedia-Oriented Javascript Libraries
|
||||
. 11 Developing With htmx
|
||||
.. 11.1 Additional Attributes
|
||||
.. 11.2 Debugging
|
||||
.. 11.3 Security Concerns
|
||||
.. 11.4 Understanding the htmx event model
|
||||
.. 11.5 Understanding the extension model
|
||||
|
||||
. 12 Other Hypermedia-Oriented Javascript Libraries
|
||||
[none]
|
||||
.. 11.1 Unpoly
|
||||
.. 11.2 Hotwire
|
||||
|
||||
@ -4,5 +4,6 @@ asciidoctor-pdf -a pdf-theme=manning -r /opt/homebrew/Cellar/asciidoctor/2.0.17/
|
||||
asciidoctor-pdf -a pdf-theme=manning -r /opt/homebrew/Cellar/asciidoctor/2.0.17/libexec/gems/asciidoctor-pdf-1.6.2/lib/sectnumoffset-treeprocessor.rb -o pdfs/generated/CH04_gross_Hypermedia_In_Action.pdf CH04_improving_our_hypermedia_application.adoc
|
||||
asciidoctor-pdf -a pdf-theme=manning -r /opt/homebrew/Cellar/asciidoctor/2.0.17/libexec/gems/asciidoctor-pdf-1.6.2/lib/sectnumoffset-treeprocessor.rb -o pdfs/generated/CH05_gross_Hypermedia_In_Action.pdf CH05_advanced_hypermedia_patterns.adoc
|
||||
asciidoctor-pdf -a pdf-theme=manning -r /opt/homebrew/Cellar/asciidoctor/2.0.17/libexec/gems/asciidoctor-pdf-1.6.2/lib/sectnumoffset-treeprocessor.rb -o pdfs/generated/CH09_gross_Hypermedia_In_Action.pdf CH09_gross_json_data_apis.adoc
|
||||
asciidoctor-pdf -a pdf-theme=manning -r /opt/homebrew/Cellar/asciidoctor/2.0.17/libexec/gems/asciidoctor-pdf-1.6.2/lib/sectnumoffset-treeprocessor.rb -o pdfs/generated/CH10_gross_Hypermedia_In_Action.pdf CH10_gross_download_ui.adoc
|
||||
asciidoctor-pdf -a pdf-theme=manning -r /opt/homebrew/Cellar/asciidoctor/2.0.17/libexec/gems/asciidoctor-pdf-1.6.2/lib/sectnumoffset-treeprocessor.rb -o pdfs/generated/Hypermedia_In_Action.pdf index.adoc
|
||||
asciidoctor-pdf -a pdf-theme=manning -r /opt/homebrew/Cellar/asciidoctor/2.0.17/libexec/gems/asciidoctor-pdf-1.6.2/lib/sectnumoffset-treeprocessor.rb -o pdfs/generated/TOC_gross_Hypermedia_In_Action.pdf TOC.adoc
|
||||
asciidoctor-pdf -a pdf-theme=manning -r /opt/homebrew/Cellar/asciidoctor/2.0.17/libexec/gems/asciidoctor-pdf-1.6.2/lib/sectnumoffset-treeprocessor.rb -o pdfs/generated/TOC_gross_Hypermedia_In_Action.pdf TOC.adoc
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -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:20220805162949-06'00')
|
||||
/CreationDate (D:20220806092936-06'00')
|
||||
/CreationDate (D:20220814160143-06'00')
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
|
||||
@ -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:20220805162953-06'00')
|
||||
/CreationDate (D:20220806092937-06'00')
|
||||
/CreationDate (D:20220814160144-06'00')
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
|
||||
@ -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:20220805164632-06'00')
|
||||
/CreationDate (D:20220806092938-06'00')
|
||||
/CreationDate (D:20220814160145-06'00')
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
|
||||
@ -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:20220805164543-06'00')
|
||||
/CreationDate (D:20220806092939-06'00')
|
||||
/CreationDate (D:20220814160146-06'00')
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -4,8 +4,8 @@
|
||||
<< /Title (Untitled)
|
||||
/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:20220805165302-06'00')
|
||||
/CreationDate (D:20220806092950-06'00')
|
||||
/ModDate (D:20220814151839-06'00')
|
||||
/CreationDate (D:20220814160153-06'00')
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
@ -886,7 +886,7 @@ endobj
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<< /Length 5386
|
||||
<< /Length 6259
|
||||
>>
|
||||
stream
|
||||
q
|
||||
@ -1038,7 +1038,7 @@ ET
|
||||
BT
|
||||
66.24 419.896 Td
|
||||
/F1.0 14 Tf
|
||||
[<313020> 55.1758 <416476616e6365642068746d78>] TJ
|
||||
[<3130204372656174696e6720> 55.1758 <41> 55.1758 <2044796e616d696320446f776e6c6f6164205549>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1057,7 +1057,7 @@ ET
|
||||
BT
|
||||
84.24 394.098 Td
|
||||
/F1.0 14 Tf
|
||||
<31302e3120436f6e66696775726174696f6e> Tj
|
||||
[<31302e3120> 55.1758 <41> 55.1758 <2044796e616d696320> 55.1758 <41726368697665205549>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1068,7 +1068,7 @@ ET
|
||||
BT
|
||||
84.24 368.3 Td
|
||||
/F1.0 14 Tf
|
||||
[<31302e3220> 55.1758 <416e696d6174696f6e73>] TJ
|
||||
<31302e3220506f6c6c696e67> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1079,7 +1079,7 @@ ET
|
||||
BT
|
||||
84.24 342.502 Td
|
||||
/F1.0 14 Tf
|
||||
<31302e3320446562756767696e67> Tj
|
||||
[<31302e3320536d6f6f7468696e6720> 18.0664 <5468696e6773204f75743a204d6f7265204f6e20> 18.0664 <5468652068746d782053776170204d6f64656c>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1090,7 +1090,7 @@ ET
|
||||
BT
|
||||
84.24 316.704 Td
|
||||
/F1.0 14 Tf
|
||||
<31302e3420536563757269747920436f6e6365726e73> Tj
|
||||
[<31302e34204469736d697373696e6720> 18.0664 <54686520446f776e6c6f6164205549>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1101,7 +1101,7 @@ ET
|
||||
BT
|
||||
84.24 290.906 Td
|
||||
/F1.0 14 Tf
|
||||
<31302e3520556e6465727374616e64696e67207468652068746d78206576656e74206d6f64656c> Tj
|
||||
[<31302e3520> 55.1758 <4175746f2d446f776e6c6f6164>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1112,7 +1112,7 @@ ET
|
||||
BT
|
||||
84.24 265.108 Td
|
||||
/F1.0 14 Tf
|
||||
<31302e3620556e6465727374616e64696e672074686520657874656e73696f6e206d6f64656c> Tj
|
||||
<31302e362053756d6d617279> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1123,7 +1123,7 @@ ET
|
||||
BT
|
||||
66.24 239.31 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <31204f746865722048797065726d656469612d4f7269656e746564204a617661736372697074204c6962726172696573>] TJ
|
||||
[<31> 37.1094 <3120446576656c6f70696e6720> 18.0664 <57> 40.0391 <6974682068746d78>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1134,6 +1134,17 @@ ET
|
||||
0.0 Tc
|
||||
|
||||
-0.5 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
68.538 213.512 Td
|
||||
/F1.0 14 Tf
|
||||
<612e> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
0.0 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
@ -1142,51 +1153,127 @@ ET
|
||||
BT
|
||||
84.24 213.512 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <312e3120556e706f6c79>] TJ
|
||||
[<31> 37.1094 <312e3120> 55.1758 <4164646974696f6e616c20> 55.1758 <41747472696275746573>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
-0.5 Tc
|
||||
|
||||
0.0 Tc
|
||||
|
||||
-0.5 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
67.74 187.714 Td
|
||||
/F1.0 14 Tf
|
||||
<622e> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
0.0 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 187.714 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <312e3220486f7477697265>] TJ
|
||||
[<31> 37.1094 <312e3220446562756767696e67>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
-0.5 Tc
|
||||
|
||||
0.0 Tc
|
||||
|
||||
-0.5 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
68.538 161.916 Td
|
||||
/F1.0 14 Tf
|
||||
<632e> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
0.0 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 161.916 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <312e33206a5175657279>] TJ
|
||||
[<31> 37.1094 <312e3320536563757269747920436f6e6365726e73>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
-0.5 Tc
|
||||
|
||||
0.0 Tc
|
||||
|
||||
-0.5 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
67.74 136.118 Td
|
||||
/F1.0 14 Tf
|
||||
<642e> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
0.0 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 136.118 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <312e3420> 18.0664 <56> 110.8398 <616e696c6c614a53>] TJ
|
||||
[<31> 37.1094 <312e3420556e6465727374616e64696e67207468652068746d78206576656e74206d6f64656c>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
-0.5 Tc
|
||||
|
||||
0.0 Tc
|
||||
|
||||
-0.5 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
68.538 110.32 Td
|
||||
/F1.0 14 Tf
|
||||
<652e> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
0.0 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 110.32 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <312e352053756d6d617279>] TJ
|
||||
[<31> 37.1094 <312e3520556e6465727374616e64696e672074686520657874656e73696f6e206d6f64656c>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1197,7 +1284,7 @@ ET
|
||||
BT
|
||||
66.24 84.522 Td
|
||||
/F1.0 14 Tf
|
||||
[<31322048797065726d656469613a20> 55.1758 <41> 55.1758 <2052657475726e20> 18.0664 <54> 69.8242 <6f20> 18.0664 <54686520> 18.0664 <57> 80.0781 <6562d5> 55.1758 <7320526f6f7473>] TJ
|
||||
<3132204f746865722048797065726d656469612d4f7269656e746564204a617661736372697074204c6962726172696573> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1274,7 +1361,7 @@ endobj
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<< /Length 1402
|
||||
<< /Length 2458
|
||||
>>
|
||||
stream
|
||||
q
|
||||
@ -1294,7 +1381,7 @@ q
|
||||
BT
|
||||
84.24 729.472 Td
|
||||
/F1.0 14 Tf
|
||||
[<31322e3120> 18.0664 <54> 35.1562 <72656e647320696e20536f66747761726520446576656c6f706d656e74>] TJ
|
||||
[<31> 37.1094 <312e3120556e706f6c79>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1305,7 +1392,7 @@ ET
|
||||
BT
|
||||
84.24 703.674 Td
|
||||
/F1.0 14 Tf
|
||||
<31322e3220436f6d706c65786974792053656c6c732c2053696d706c696369747920456e6475726573> Tj
|
||||
[<31> 37.1094 <312e3220486f7477697265>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
@ -1316,6 +1403,80 @@ ET
|
||||
BT
|
||||
84.24 677.876 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <312e33206a5175657279>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 652.078 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <312e3420> 18.0664 <56> 110.8398 <616e696c6c614a53>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 626.28 Td
|
||||
/F1.0 14 Tf
|
||||
[<31> 37.1094 <312e352053756d6d617279>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
66.24 600.482 Td
|
||||
/F1.0 14 Tf
|
||||
[<31322048797065726d656469613a20> 55.1758 <41> 55.1758 <2052657475726e20> 18.0664 <54> 69.8242 <6f20> 18.0664 <54686520> 18.0664 <57> 80.0781 <6562d5> 55.1758 <7320526f6f7473>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
|
||||
-0.5 Tc
|
||||
|
||||
0.0 Tc
|
||||
|
||||
-0.5 Tc
|
||||
|
||||
0.0 Tc
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 574.684 Td
|
||||
/F1.0 14 Tf
|
||||
[<31322e3120> 18.0664 <54> 35.1562 <72656e647320696e20536f66747761726520446576656c6f706d656e74>] TJ
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 548.886 Td
|
||||
/F1.0 14 Tf
|
||||
<31322e3220436f6d706c65786974792053656c6c732c2053696d706c696369747920456e6475726573> Tj
|
||||
ET
|
||||
|
||||
0.0 0.0 0.0 SCN
|
||||
0.0 0.0 0.0 scn
|
||||
0.2 0.2 0.2 scn
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
84.24 523.088 Td
|
||||
/F1.0 14 Tf
|
||||
<31322e332053756d6d617279> Tj
|
||||
ET
|
||||
|
||||
@ -1325,7 +1486,7 @@ ET
|
||||
0.2 0.2 0.2 SCN
|
||||
|
||||
BT
|
||||
66.24 652.078 Td
|
||||
66.24 497.29 Td
|
||||
/F1.0 14 Tf
|
||||
[<417070656e64697820313a20> 55.1758 <41> 55.1758 <20526576696577206f6620436861707465722035206f6620526f79204669656c64696e67d5> 55.1758 <7320446973736572746174696f6e204f6e20> 18.0664 <54686520> 18.0664 <57> 80.0781 <6562>] TJ
|
||||
ET
|
||||
@ -1903,35 +2064,35 @@ xref
|
||||
0000006973 00000 n
|
||||
0000011896 00000 n
|
||||
0000012223 00000 n
|
||||
0000017662 00000 n
|
||||
0000017989 00000 n
|
||||
0000019444 00000 n
|
||||
0000019771 00000 n
|
||||
0000019845 00000 n
|
||||
0000019980 00000 n
|
||||
0000020111 00000 n
|
||||
0000020201 00000 n
|
||||
0000020467 00000 n
|
||||
0000020733 00000 n
|
||||
0000020913 00000 n
|
||||
0000021177 00000 n
|
||||
0000021441 00000 n
|
||||
0000032302 00000 n
|
||||
0000032525 00000 n
|
||||
0000033879 00000 n
|
||||
0000034793 00000 n
|
||||
0000076282 00000 n
|
||||
0000076500 00000 n
|
||||
0000077854 00000 n
|
||||
0000078768 00000 n
|
||||
0000089708 00000 n
|
||||
0000089940 00000 n
|
||||
0000091294 00000 n
|
||||
0000018535 00000 n
|
||||
0000018862 00000 n
|
||||
0000021373 00000 n
|
||||
0000021700 00000 n
|
||||
0000021774 00000 n
|
||||
0000021909 00000 n
|
||||
0000022040 00000 n
|
||||
0000022130 00000 n
|
||||
0000022396 00000 n
|
||||
0000022662 00000 n
|
||||
0000022842 00000 n
|
||||
0000023106 00000 n
|
||||
0000023370 00000 n
|
||||
0000034231 00000 n
|
||||
0000034454 00000 n
|
||||
0000035808 00000 n
|
||||
0000036722 00000 n
|
||||
0000078211 00000 n
|
||||
0000078429 00000 n
|
||||
0000079783 00000 n
|
||||
0000080697 00000 n
|
||||
0000091637 00000 n
|
||||
0000091869 00000 n
|
||||
0000093223 00000 n
|
||||
trailer
|
||||
<< /Size 41
|
||||
/Root 2 0 R
|
||||
/Info 1 0 R
|
||||
>>
|
||||
startxref
|
||||
92208
|
||||
94137
|
||||
%%EOF
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user