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:
This is the post where we give it a make over, so that it starts looking more like this…
…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.