HOME

With style: Dev Journal 6

This post is part 6 of the "Dev Journal" series. Part 1 contains the series index, while the DevJournal6 tag for the CalDance project in GitLab holds the state of the repository as described here.

In theory, our log in mechanism works. But in reality it looks like this:

before.png
Figure 1: Two unstyled, unlabeled text boxes next to a "Submit" button

This is the post where we give it a make over, so that it starts looking more like this…

after.png
Figure 2: A snazzy looking login page

…while also starting to build in some interactivity, usability, and feedback via HTMX.

The good, the bad, and the ugly

You may remember from part 1 that HTMX and Falco's markup library are both tools I'm trying out for the first time. This means that while I'm happy with the results I achieved in this post, I'm not all that happy with the resulting code. Yet. There will be a refactoring follow up.

Which translates to: don't take anything as an active recommendation of how to do things, but a chance to follow along as I learn a new tool.

The logic behind our changes

My first attempt at nice server side UI building hinges on two key ideas. 1) each domain module should be responsible for its own UI requirements and 2) the overall UI should look coherent.

This sounds like a place for a style guide, so I created a StyleGuide directory and started hacking. We ended up with four files in here, each with their own little area of responsibility.

Htmx

The Htmx.fs file (link to the diff) is arguably not really part of the style guide, but it seemed the best place I had to put it.

It defines a series of HTMX related attributes that I can then add to elements in other places without worrying about misspelling them.

let post url = Attr.create "hx-post" url
let get url = Attr.create "hx-get" url
let target elemId = Attr.create "hx-target" elemId
let swap details = Attr.create "hx-swap" details
let boost = Attr.createBool "hx-boost"
let indicator selector = Attr.create "hx-indicator" selector

It also provides a helper for endpoints responding to requests which may or may not be coming from HTMX. Remember that HTMX works by allowing you to respond to a request with a fragment of HTML which will then get embedded into the already loaded page, rather than requiring a full page refresh. This is great, but it means that endpoints which represent a "whole page" can end up being called in one of two ways: by HTMX wanting just the body of the page to embed, and by the browser trying to just load a URL.

It felt like the logic for branching between these scenarios was going to come up enough it was worth capturing in a named function, so I did:

let htmxOrFull branches =
  handler {
    let! headers = Request.getHeaders |> Handler.fromCtx

    let hasHxRequestHeader =
      headers.Keys.Contains "HX-Request"

    let isRequestingFullPage =
      match
        headers.TryGetBoolean "HX-History-Restore-Request"
      with
      | Some true -> true
      | Some false
      | None -> false

    if hasHxRequestHeader && (not isRequestingFullPage) then
      return! branches.onHtmx
    else
      return! branches.onFull
  }

We'll be seeing this again in a bit.

Modifier

I'm planning on using Bulma as the basis for my CSS as it hits a reasonably sweet spot for me between having a good enough version of "most things" built in and not requiring me to mutate my HTML too much to accommodate it. So the next thing to add was constants for some of the most common modifier classes that Bulma supports.

module Mavnn.CalDance.StyleGuide.Modifiers

open Falco.Markup

let isPrimary = Attr.class' "is-primary"

let isLink = Attr.class' "is-link"

let isInfo = Attr.class' "is-info"

let isSuccess = Attr.class' "is-info"

let isWarning = Attr.class' "is-warning"

let isDanger = Attr.class' "is-danger"

Boom. Done. Compiler as a spellcheck, tick.

Layout

As with the modifiers, I wanted to make it a little bit easier to do the "right thing" when creating a view, so I set up Layout.fs (link to the diff) which includes a page function that takes a title and a list of sections and a set of broadly applicable elements like titles and links.

At the moment the page template loads all of the libraries from shared CDNs, which is something we'll want to change before going to production. We're grabbing Bulma and HTMX as you'd expect, and also the "morphing" library written by the HTMX authors which attempts to only replace elements in the DOM that have actively changed. We also add a meta element to tell HTMX that when it adds a class to an element to signify it is loading, it should use the is-loading class from Bulma rather than the htmx-request class it defaults to.

Form

The Form.fs module (link to the diff) is the place where I feel I've probably over engineered things. I started putting together a set of builder helpers and types for building forms and… yeah. I don't know. I think it's probably ended up a case of trying to add the abstraction before building the second use of something, and it shows. I'm not all that happy with the code that results.

I'm not going to go into too much detail on this one, I'm just going to show it in use and remind the reader that this API may change in the future.

Actually doing the thing

With our helpers constructed, we can start using them. Simple full page endpoints are quite simple; we just swap in the new Layout functions and we're good to go. For example, the view for the home page now looks like this:

Layout.page
  "Home"
  [ Layout.containerSection
      [ Layout.title
          Layout.T1
          (match user with
            | Some u -> $"Hi {u.username}!"
            | None -> "You should go log in!")
        Layout.paragraphX
          []
          [ Text.raw "Would you like to "
            Layout.link
              (greeting.greetingLink "Bob")
              "greet Bob?" ] ] ]

As soon as we get to adding things like navigation bars to the page template they will all just appear.

The magic, again, begins in the User.fs module. Let's have a think about the request life cycle with HTMX.

Option 1: the user GETS the log in (or sign up) page

In this case, we want to send a full page back to the user with an empty "user details" form; this form should not show any validation errors (don't you hate it when a form tells you empty fields aren't allowed before you've started typing?!).

Option 2: the user POSTS invalid user data

Well, if the form fields just aren't in the POST we should return a 400: something is just broken. But if the correct fields exist and this request is flagged as being made by HTMX, what we want to do is update the form with the information about what the user needs to change. Preferably without removing all the information they've already added!

Option 3: the user POSTS valid user data

In this case we want to log the user in and navigate them somewhere else in the website. We don't just want to return the form, we want to return the special HX-Location header which tells HTMX "load the body of that location and substitute it in to avoid a full page reload".

In the case where we return an updated form, it is critical that as closely as possible it has exactly the same HTML structure as before to allow the merge logic to do its thing, so to allow that I built a "user data form" builder function that does all the things we need it to.

It's a bit of a monster, but let's have a look:

let private userForm
  csrfToken
  location
  usernameValue
  usernameProb
  passwordValue
  passwordProb
  =
  let userInput =
    Form.InputConfig.make "text" "username" "Your username"
    |> Form.InputConfig.addLabel "Username"
    |> Form.InputConfig.addIcons (Form.Left "mdi-account")
    |> Form.InputConfig.setValue usernameValue
    |> fun ic ->
        match usernameProb with
        | Some prob -> Form.InputConfig.addError prob ic
        | None -> ic
    |> Form.input

  let passwordInput =
    Form.InputConfig.make
      "password"
      "password"
      "Your password"
    |> Form.InputConfig.addLabel "Password"
    |> Form.InputConfig.addIcons (Form.Left "mdi-lock")
    |> Form.InputConfig.setValue passwordValue
    |> fun ic ->
        match passwordProb with
        | Some prob -> Form.InputConfig.addError prob ic
        | None -> ic
    |> Form.input

  Form.form
    { csrfToken = csrfToken
      id = "userform"
      modifiers =
        [ Htmx.post location
          Htmx.target "closest form"
          Htmx.indicator "#userFormSubmit button"
          Htmx.swap "morph:{ignoreActiveValue:true}" ]
      controls =
        [ userInput
          passwordInput
          Form.button
            "userFormSubmit"
            "submit"
            "Submit"
            [ Modifiers.isPrimary ]
            "Submit" ] }

The start of the function builds are two input fields, and then the interactive logic is all contained within the 4 HTMX attributes towards the end. These tell HTMX that it should post the form values to the location specified, place a loading indicator on the button within the element with ID userFormSubmit, and then should try and morph the HTML it gets back into the closest form element.

Now are post methods can return one of two different responses (assuming that we have form data, etc); if authentication succeeds we can send an empty 200 response with a location header and our session cookies:

let private signIn authScheme principal url =
  handler {
    do!
      Handler.fromCtxTask (fun ctx ->
        task { do! Auth.signIn authScheme principal ctx })

    return!
      Handler.fromCtx (
        Response.withHeaders [ "HX-Location", url ]
        >> ignore
      )
  }

If the data is invalid, we can respond with a form containing the relevant error messages, like so:

let private authenticationFailed formData location =
  let failedAuth =
    "Matching username and password not found"

  Response.ofHtmlCsrf (fun token ->
    userForm
      token
      location
      (Some formData.username)
      (Some failedAuth)
      (Some formData.password)
      (Some failedAuth))

Notice that we're carry through the form data that was posted to us rather than clearing the form out on every submit.

This is also the module where we start making use of the HTMX branching helper we set up above, so we can add endpoints like:

let private logoutEndpoint routeNamespace =
  Handler.toEndpoint
    get
    (logoutRoute routeNamespace)
    (fun () ->
      Htmx.htmxOrFull
        { onHtmx =
            handler {
              do! signOut "Cookies" "/"
              return Response.ofEmpty
            }
          onFull =
            handler {
              return
                Response.signOutAndRedirect "Cookies" "/"
            } })

Browsing directly to the log out link in your browser will get you a redirect status code response, while clicking a log out link within the web app will take you back to the index page (logged out!) without having to do a full page refresh.

That's a wrap

So, that's the main changes for this post. As normal there's the link at the top of the post to the repo as it was when the post was written. I'm not totally happy with the internal results here, but I'm happy enough that I don't want to spend time refactoring it before I've started using it on a second use case.

Speaking of which, keep an eye out for the next post where we'll actually let a user do something.