Posts Tagged “events”

Monday, November 29, 2021
  A Tour of myPrayerJournal v3: Bootstrap Integration

NOTE: This is the third post in a series; see the introduction for information on requirements and links to other posts in the series.

Many modern Single Page Application (SPA) frameworks include (or have plugins for) CSS transitions and effects. Combined with the speed of not having to do a full refresh, this is one of their best features. One might not think that a framework like htmx, which simply swaps out sections of the page, would have this; but if one were to think that, one would be wrong. Sadly, though, I did not utilize those aspects of htmx while I was migrating myPrayerJournal from v2 to v3; however, I will highlight the htmx way to do this in last section of this post.

myPrayerJournal v2 used a Vue plugin that provided Bootstrap v4 support; myPrayerJournal v3 uses Bootstrap v5. The main motivation I had to remain with Bootstrap was that I liked the actual appearance, and I know how it works. The majority of my “learning” on this project dealt with htmx; I did not want to add a UI redesign to the mix. Before we jump into the implementation, let me briefly explain the framework.

About Bootstrap

Bootstrap was originally called Twitter Bootstrap; it was the CSS framework that Twitter developed in their early iterations. It was, by far, the most popular framework at the time, and it was innovative in its grid layout system. Long before there was browser support for the styles that make layouts much easier to develop, and more responsive to differing screen sizes, Bootstrap's grid layout and size breakpoints made it easy to build a website that worked for desktop, tablet, or phone. Of course, there is a limit to what you can do with styling, so Bootstrap also has a JavaScript library that augments these styles, enabling the interactivity to which the modern web user is accustomed.

Version 5 of Bootstrap continues this tradition; however, it brings in even more utility classes, and supports Flex layouts as well. It is a mature library that continues to be maintained, and the project's philosophy seems to be “just enough” - it's not going to do everything for everyone, but in the majority of cases, it has exactly what the developer needs. It is not a bloated library that needs tree-shaking to avoid a ridiculous download size.

It is, by far, the largest payload in the initial page request:

  • Bootstrap - 48.6 kB (CSS is 24.8 kB; JavaScript is 23.8 kB, deferred until after render)
  • htmx - 11.8 kB
  • myPrayerJournal - 4.4 kB (CSS is 1.2 kB, JavaScript is 3.2 kB)

However, this gets the entire style and script, and allows us to use their layouts and interactive components. But, how do we get that interactivity from the server?

Hooking in to the htmx Request Pipeline

htmx provides several events to which an application can listen. In myPrayerJournal v3, I used htmx:afterOnLoad because I did not need the new content to be swapped in yet when the function fired. There are afterSwap and afterSettle events which will fire once those events have occurred, if you need to defer processing until those are complete.

There are two different Bootstrap script-driven components myPrayerJournal uses; let's take a look at toasts.

A Toast to Via htmx

Toasts are pop-up notifications that appear on the screen, usually for a short time, then fade out. In some cases, particularly if the toast is alerting the user to an error, it will stay on the screen until the user dismisses it, usually by clicking an “x” in the upper right-hand corner (even if the developer used a Mac!). Bootstrap provides a host of options for their toast component; for our uses, though, we will:

  • Place toasts in the bottom right-hand corner;
  • Allow multiple toasts to be visible at once;
  • Auto-hide success toasts; require others to be dismissed manually.

There are several different aspects that make this work.

The Toaster

Just like IRL toast comes out of a toaster, our toasts need a place from which to emerge. In the prior post, I mentioned that the footer does not get reloaded when a “page” request is made. There is also an element above the footer that also remains across these requests - defined here as the “toaster” (my term, not Bootstrap's).

/// Element used to display toasts
let toaster =
  div [ _ariaLive "polite"; _ariaAtomic "true"; _id "toastHost" ] [
    div [ _class "toast-container position-absolute p-3 bottom-0 end-0"; _id "toasts" ] []
    ]

This renders two empty divs with the appropriate style attributes; toasts placed in the #toasts div will display as we want them to.

Showing the Toast

Bootstrap provides data- attributes that can make toasts appear; however, since we are creating these in script, we need to use their JavaScript functions. The message coming from the server has the format TYPE|||The message. Let's look at the showToast function (the largest custom JavaScript function in the entire application):

const mpj = {
  // ...
  showToast (message) {
    const [level, msg] = message.split("|||")
  
    let header
    if (level !== "success") {
      const heading = typ => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
    
      header = document.createElement("div")
      header.className = "toast-header"
      header.innerHTML = heading(level === "warning" ? level : "error")
    
      const close = document.createElement("button")
      close.type = "button"
      close.className = "btn-close"
      close.setAttribute("data-bs-dismiss", "toast")
      close.setAttribute("aria-label", "Close")
      header.appendChild(close)
    }

    const body = document.createElement("div")
    body.className = "toast-body"
    body.innerText = msg
  
    const toastEl = document.createElement("div")
    toastEl.className = `toast bg-${level === "error" ? "danger" : level} text-white`
    toastEl.setAttribute("role", "alert")
    toastEl.setAttribute("aria-live", "assertlive")
    toastEl.setAttribute("aria-atomic", "true")
    toastEl.addEventListener("hidden.bs.toast", e => e.target.remove())
    if (header) toastEl.appendChild(header)
  
    toastEl.appendChild(body)
    document.getElementById("toasts").appendChild(toastEl)
    new bootstrap.Toast(toastEl, { autohide: level === "success" }).show()
  },
  // ...
}

Here's what's going on in the code above:

  • Line 4 splits the level from the message
  • Lines 6-20 (let header) create a header and close button if the message is not a success
  • Lines 22-24 (const body) create the body div with attributes Bootstrap's styling expects
  • Lines 26-30 (const toastEl) create the div that will contain the toast
  • Line 31 adds an event handler to remove the element from the DOM once the toast is hidden
  • Lines 32 and 34 add the optional header and mandatory body to the toast div
  • Line 35 adds the toast to the page (within the toasts inner div defined above)
  • Line 36 initializes the Bootstrap JavaScript component, auto-hiding on success, and shows the toast

(If you've never used JavaScript to create elements that are added to an HTML document, this probably looks weird and verbose; if you have, you look at it and think "well, they're not wrong…")

So, we have our toaster, we know how to put bread notifications in it - but how do we get the notifications from the server?

Receiving the Toast

The code to handle this is part of the htmx:afterOnLoad handler:

htmx.on("htmx:afterOnLoad", function (evt) {
  const hdrs = evt.detail.xhr.getAllResponseHeaders()
  // Show a message if there was one in the response
  if (hdrs.indexOf("x-toast") >= 0) {
    mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
  }
  // ...
})

This looks for a custom HTTP header of X-Toast (all headers are lowercase from that xhr call), and if it's found, we pass the value of that header to the function above. This check occurs after every htmx network request, so there is nothing special to configure; “page” requests are not the only requests capable of returning a toast notification.

There is one more part; how does the toast get to the browser?

Sending the Toast

The last paragraph gave it away; we set a header on the response. This seems straightforward, and is in most cases; but once again, POST-Redirect-GET (P-R-G) complicates things. Here are the final two lines of the successful path of the request update handler:

Messages.pushSuccess ctx "Prayer request updated successfully" nextUrl
return! seeOther nextUrl next ctx

If we set a message in the response header, then redirect (remember that XMLHttpRequest handles redirects silently), the header gets lost in the redirect. Here, Messages.pushSuccess places the success message (and return URL) in a dictionary, indexed by the user's ID. Within the function that renders every result (partial, “page”-like, or full results), this dictionary is checked for a message and URL, and if one exists, it includes it. (If it is returned to the function below, it has already been removed from the dictionary.)

/// Send a partial result if this is not a full page load (does not append no-cache headers)
let partialStatic (pageTitle : string) content : HttpHandler =
  fun next ctx -> backgroundTask {
    let  isPartial = ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
    let! pageCtx   = pageContext ctx pageTitle content
    let  view      = (match isPartial with true -> partial | false -> view) pageCtx
    return! 
      (next, ctx)
      ||> match user ctx with
          | Some u ->
              match Messages.pop u with
              | Some (msg, url) -> setHttpHeader "X-Toast" msg >=> withHxPush url >=> writeView view
              | None -> writeView view
          | None -> writeView view
    }

A quick overview of this function:

  • Line 4 determines if this an htmx boosted request (a “page”-like requests)
  • Line 5 creates a rendering context for the page
  • Line 6 renders the view to a string, calling partial or view with the page rendering context
  • Lines 10-13 are only executed if a user is logged on, and line 12 is the one that appends a message and a new URL

A quick note about line 12: the >=> operator joins Giraffe HttpHandlers together. An HttpHandler takes an HttpContext and the next function to be executed, and returns a Task<HttpContext option> (an asynchronous call that may or may not return a context). If there is no context returned, the chain stops; the function can also return an altered context. It is good practice for an HttpHandler to make a single change to the context; this keeps them simple, and allows them to be plugged in however the developer desires. Thus, the setHttpHeader call adds the X-Toast header, the withHxPush call adds the HX-Push header, and the writeView call sets the response body to the rendered view.

The new URL part does not actually make the browser do anything; it simply pushes the given URL onto the browser's history stack. Technically, the browser receives the content from the P-R-G as the response to its POST; as we're replacing the current page, though, we need to make sure the URL stays in sync.

Of note is that not all toasts are this complex. For example, the “cancel snooze” handler return looks like this:

return! (withSuccessMessage "Request unsnoozed" >=> Components.requestItem requestId) next ctx

...while the withSuccessMessage handler is:

/// Add a success message header to the response
let withSuccessMessage : string -> HttpHandler =
  sprintf "success|||%s" >> setHttpHeader "X-Toast"

No dictionary, no redirect, just a single response that will show a toast.

You made it - the toast section is toast! There is one more interesting interaction, though; that of the modal dialog.

Bootstrap's implementation of modal dialogs also uses JavaScript; however, for the purposes of the modals used in myPrayerJournal v3, we can use the data- attributes to show them. Here is the view for a modal dialog that allows the user to snooze a request (hiding it from the active list until the specified date); this is rendered a single time on the journal view page:

div [
  _id             "snoozeModal"
  _class          "modal fade"
  _tabindex       "-1"
  _ariaLabelledBy "snoozeModalLabel"
  _ariaHidden     "true"
  ] [
  div [ _class "modal-dialog modal-sm" ] [
    div [ _class "modal-content" ] [
      div [ _class "modal-header" ] [
        h5 [ _class "modal-title"; _id "snoozeModalLabel" ] [ str "Snooze Prayer Request" ]
        button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
        ]
      div [ _class "modal-body"; _id "snoozeBody" ] [ ]
      div [ _class "modal-footer" ] [
        button [ _type "button"; _id "snoozeDismiss"; _class "btn btn-secondary"; _data "bs-dismiss" "modal" ] [
          str "Close"
          ]
        ]
      ]
    ]
  ]

Notice that #snoozeBody is empty; we fill that when the user clicks the snooze icon:

button [
  _type     "button"
  _class    "btn btn-secondary"
  _title    "Snooze Request"
  _data     "bs-toggle" "modal"
  _data     "bs-target" "#snoozeModal"
  _hxGet    $"/components/request/{reqId}/snooze"
  _hxTarget "#snoozeBody"
  _hxSwap   HxSwap.InnerHtml
  ] [ icon "schedule" ]

This uses data-bs-toggle and data-bs-target, Bootstrap attributes, to show the modal. It also uses hx-get to load the snooze form for that particular request, with hx-target targeting the #snoozeBody div from the modal definition. Here is how that form is defined:

/// The snooze edit form
let snooze requestId =
  let today = System.DateTime.Today.ToString "yyyy-MM-dd"
  form [
    _hxPatch  $"/request/{RequestId.toString requestId}/snooze"
    _hxTarget "#journalItems"
    _hxSwap   HxSwap.OuterHtml
    ] [
    div [ _class "form-floating pb-3" ] [
      input [ _type "date"; _id "until"; _name "until"; _class "form-control"; _min today; _required ]
      label [ _for "until" ] [ str "Until" ]
      ]
    p [ _class "text-end mb-0" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Snooze" ] ]
    ]

Here, the form uses hx-patch to submit the data to the snooze endpoint. The target for the response, though, is #journalItems; this is the element that holds all of the prayer request cards. Snoozing a request will remove it from the active list, so the list needs to be refreshed; this will make that happen.

Look back at the modal definition; at the bottom, there is a “Close” button. We will use this to dismiss the modal once the update succeeds. In the Giraffe handler to snooze a request, here is its return statement:

return!
  (withSuccessMessage $"Request snoozed until {until.until}"
  >=> hideModal "snooze"
  >=> Components.journalItems) next ctx

Notice that hideModal handler?

/// Hide a modal window when the response is sent
let hideModal (name : string) : HttpHandler =
  setHttpHeader "X-Hide-Modal" name

Yes, it's another HTTP header! One can certainly get carried away with custom HTTP headers, but their very existence is to communicate with the client (browser) outside of the visible content of the page. Here, we're passing the name “snooze” to this header; in our htmx:afterOnLoad handler, we'll consume this header:

htmx.on("htmx:afterOnLoad", function (evt) {
  const hdrs = evt.detail.xhr.getAllResponseHeaders()
  // ...
  // Hide a modal window if requested
  if (hdrs.indexOf("x-hide-modal") >= 0) {
    document.getElementById(evt.detail.xhr.getResponseHeader("x-hide-modal") + "Dismiss").click()
  }
})

The “Close” button on our modal was given the id of snoozeDismiss; this mimics the user clicking the button, which Bootstrap's data- attributes handle from there. Of all the design choices and implementations I did in this conversion, this part strikes me as the most "hack"y. However, I did try to hook into the Bootstrap modal itself, and hide it via script; however, it didn't like initializing a modal a second time, and I could not get a reference to it from the htmx:afterOnLoad handler. Clicking the button works, though, even when it's done from script.

CSS Transitions in htmx

This post has already gotten much longer than I had planned, but I wanted to make sure I covered this.

  • When htmx requests are in flight, the framework makes it easy to show indicators.
  • I mentioned swapping and settling when discussing the events htmx exposes. The way this is done, CSS transitions will render as expected. They have a host of examples to spark your imagination.

As I was keeping the UI the same, I did not end up using these options; however, their presence demonstrates that htmx is a true batteries-included SPA framework.


Up next, we'll step away from the front end and dig into LiteDB.

Categorized under , , ,
Tagged , , , , , , , , , , , , , ,

Saturday, August 25, 2018
  A Tour of myPrayerJournal: The Front End

NOTES:

  • This is post 2 in a series; see the introduction for all of them, and the requirements for which this software was built.
  • Links that start with the text “mpj:” are links to the 1.0.0 tag (1.0 release) of myPrayerJournal, unless otherwise noted.

Vue is a front-end JavaScript framework that aims to have very little boilerplate and ceremony, while still presenting a componentized abstraction that can scale to enterprise-level if required1. Vue components can be coded using inline templates or multiple files (splitting code and template). Vue also provides Single File Components (SFCs, using the .vue extension), which allow you to put template, code, and style all in the same spot; these encapsulate the component, but allow all three parts to be expressed as if they were in separate files (rather than, for example, having an HTML snippet as a string in a JavaScript file). The Vetur plugin for Visual Studio Code provides syntax coloring support for each of the three sections of the file.

Layout

Using the default template, main.js is the entry point; it creates a Vue instance and attaches it to an element named app. This file also supports registering common components, so they do not have to be specifically imported and referenced in components that wish to use them. For myPrayerJournal, we registered our common components there (mpj:main.js). We also registered a few third-party Vue components to support a progress bar (activated during API activity) and toasts (pop-up notifications).

App.vue is also part of the default template, and is the component that main.js attaches to the app elements (mpj:App.vue). It serves as the main template for our application; if you have done much template work, you'll likely recognize the familiar pattern of header/content/footer.

This is also our first look at an SFC, so let's dig in there. The top part is the template; we used Pug (formerly Jade) for our templates. The next part is enclosed in script tags, and is the script for the page. For this component, we import one additional component (Navigation.vue) and the version from package.json, then export an object that conforms to Vue's expected component structure. Finally, styles for the component are enclosed in style tags. If the scoped attribute is present on the style tag, Vue will generate data attributes for each element, and render the declared styles as only affecting elements with that attribute. myPrayerJournal doesn't use scoped styles that much; Vue recommends classes instead, if practical, to reduce complexity in the compiled app.

Also of note in App.js is the code surrounding the use of the toast component. In the template, it's declared as toast(ref='toast'). Although we registered it in main.js and can use it anywhere, if we put it in other components, they create their own instance of it. The ref attribute causes Vue to generate a reference to that element in the component's $refs collection. This enables us to, from any component loaded by the router (which we'll discuss a bit later), access the toast instance by using this.$parent.$refs.toast, which allows us to send toasts whenever we want, and have the one instance handle showing them and fading them out. (Without this, toasts would appear on top of each other, because the independent instances have no idea what the others are currently showing.)

Routing

Just as URLs are important in a regular application, they are important in a Vue app. The Vue router is a separate component, but can be included in the new project template via the Vue CLI. In App.vue, the router-view item renders the output from the router; we wire in the router in main.js. Configuring the router (mpj:router.js) is rather straightforward:

  • Import all of the components that should appear to be a page (i.e., not modals or common components)
  • Assign each route a path and name, and specify the component
  • For URLs that contain data (a segment starting with :), ensure props: true is part of the route configuration

The scrollBehavior function, as it appears in the source, makes the Vue app mimic how a traditional web application would handle scrolling. If the user presses the back button, or you programmatically go back 1 page in history, the page will return to the point where it was previously, not the top of the page.

To specify a link to a route, we use the router-link tag rather than a plain a tag. This tag takes a :to parameter, which is an object with a name property; if it requires parameters/properties, a params property is included. mpj:Navigation.vue is littered with the former; see the showEdit method in mpj:RequestCard.vue for the structure on the latter (and also an example of programmatic navigation vs. router-link).

Components

When software developers hear “components,” they generally think of reusable pieces of software that can be pulled together to make a system. While that isn't wrong, it's important to understand that “reusable” does not necessarily mean “reused.” For example, the privacy policy (mpj:PrivacyPolicy.vue) is a component, but reusing it throughout the application would be… well, let's just say a “sub-optimal” user experience.

However, that does not mean that none of our components will be reused. RequestCard, which we referenced above, is used in a loop in the Journal component (mpj:Journal.vue); it is reused for every request in the journal. In fact, it is reused even for requests that should not be shown; behavior associated with the shouldDisplay property makes the component display nothing if a request is snoozed or is in a recurrence period. Instead of the journal being responsible for answering the question "Should I display this request?", the request display answers the question "Should I render anything?". This may seem different from typical server-side page generation logic, but it will make more sense once we discuss state management (next post).

Looking at some other reusable (and reused) components, the page title component (mpj:PageTitle.vue) changes the title on the HTML document, and optionally also displays a title at the top of the page. The “date from now” component (mpj:DateFromNow.vue) is the most frequently reused component. Every time it is called, it generates a relative date, with the actual date/time as a tool tip; it also sets a timeout to update this every 10 seconds. This keeps the relative time in sync, even if the router destination stays active for a long time.

Finally, it's also worth mentioning that SFCs do not have to have all three sections defined. Thanks to conventions, and depending on your intended use, none of the sections are required. The “date from now” component only has a script section, while the privacy policy component only has a template section.

Component Interaction

Before we dive into the specifics of events, let's look again at Journal and RequestCard. In the current structure, RequestCard will always have Journal as a parent, and Journal will always have App as its parent. This means that RequestCard could, technically, get its toast implementation via this.$parent.$parent.toast; however, this type of coupling is very fragile2. Requiring toast as a parameter to RequestCard means that, wherever RequestCard is implemented, if it's given a toast parameter, it can display toasts for the actions that would occur on that request. Journal, as a direct descendant from App, can get its reference to the toast instance from its parent, then pass it along to child components; this only gives us one layer of dependency.

In Vue, generally speaking, parent components communicate with child components via props (which we see with passing the toast instance to RequestCard); child components communicate with parents via events. The names of events are not prescribed; the developer comes up with them, and they can be as terse or descriptive as desired. Events can optionally have additional data that goes with it. The Vue instance supports subscribing to event notifications, as well as emitting events. We can also create a separate Vue instance to use as an event bus if we like. myPrayerJournal uses both of these techniques in different places.

As an example of the first, let's look at the interaction between ActiveRequests (mpj:ActiveRequests.vue) and RequestListItem (mpj:RequestListItem.vue). On lines 41 and 42 of ActiveRequests (the parent), it subscribes to the requestUnsnoozed and requestNowShown events. Both these events trigger the page to refresh its underlying data from the journal. RequestListItem, lines 67 and 79, both use this.$parent.$emit to fire off these events. This model allows the child to emit events at will, and if the parent does not subscribe, there are no errors. For example, AnswerdRequests (mpj:AnsweredRequests.vue) does not subscribe to either of these events. (RequestListItem will not show the buttons that cause those events to be emitted, but even if it did, emitting the event would not cause an error.)

An example of the second technique, a dedicated parent/child event bus, can be seen back in Journal and RequestCard. Adding notes and snoozing requests are modal windows3. Rather than specifying an instance of these per request, which could grow rather quickly, Journal only instantiates one instance of each modal (lines 19-22). It also creates the dedicated Vue instance (line 46), and passes it to the modal windows and each RequestCard instance (lines 15, 20, and 22). Via this event bus, any RequestCard instance can trigger the notes or snooze modals to be shown. Look through NotesEdit (mpj:NotesEdit.vue) to see how the child listens for the event, and also how it resets its state (the closeDialog() method) so it will be fresh for the next request.

 

That wraps up our tour of Vue routes and components; next time, we'll take a look at Vuex, and how it helps us maintain state in the browser.


1 That's my summary; I'm sure they've got much more eloquent ways to describe it.

2 ...and kinda ugly, but maybe that's just me.

3 Up until nearly the end of development, editing requests was a modal as well. Adding recurrence made it too busy, so it got to be its own page.

Categorized under , ,
Tagged , , , , , , , , , , , ,