Fullstack Roc + htmx—Data Table
~15 mins reading time
- Demonstration
- Refactor into Model-View-Controller
- The BigTask Controller
- Protecting Routes
- GET '/'—List BigTasks
- Views.BigTask.page
- Data Table Inputs
- Views.Bootstrap.renderDataTable
- PUT '/customerID/
'—Update CustomerReferenceID - Form Input Validaiton
- Download CSV Data
- Client-side State Management
- Final thoughts
In this article, I continue experimenting with lukewilliamboswell/roc-htmx-playground and build a Data Table. The inspiration for this comes from the idea of having a table in an SQL database and wanting a simple way to display and edit this using fullstack roc + htmx.
There were a few things I wanted to explore with this including; making individual columns sortable, pagination, filtering the table results, and making a download button to get the data as a CSV.
In summary, I have been very pleased with the experience writing this demo, and I am convinced this will be a great way to build larger web applications in the future.
Here is a demonstration of the data table in action.
Demonstration
Code for this article is available at this commit.
Refactor into Model-View-Controller
While writing the code for this demo, I found myself naturally refactoring as I went. It was such a pleasant experience discovering a new pattern and incrementally cleaning up tech debt.
After several changes, I noticed an MVC pattern emerging in how I was structuring the code. I recall hearing Carson Gross the creator of HTMX talking about this pattern from a podcast, so figured I would rename the modules, and commit to testing this out. So far it has been working nicely and I haven't found any issues working with roc modules.
The main components are:
- Models are the data structures and logic without any dependencies. These modules are where the "business logic" of my application is implemented.
- Views are pure functions and helpers to render data structures (such as defined in the Models) to HTML.
- Controllers define and handle the routes for HTTP requests. Things like parsing URL query parameters or form submissions, making SQL queries to the DB, and handling invalid requests happen in these modules.
The BigTask Controller
The BigTask controller is a top-level function that takes a Request
along with additional parameters.
# src/Controllers/BigTask.roc respond : { req : Request, urlSegments : List Str, dbPath : Str, session : Session, } -> Task Response _
This is called in main.roc
to handle all of the requests that start with the url segment /bigTask
.
# main.roc (_, ["bigTask", ..]) -> Controllers.BigTask.respond { req, urlSegments : List.dropFirst urlSegments 1, dbPath, session, }
Protecting Routes
I wanted to test the idea of how to "protect" some routes, so they are only available to authenticated users. If someone is a Guest
they shouldn't be able to access sensitive information. Instead, the server should respond appropriately with an error message.
All of our app logic is managed in the server (as opposed to in a client-side application), so it is much easier to verify the user is authenticated. The session is managed in the database, so we can confirm the user has previously authenticated using a simple helper function.
# src/Models/Session.roc isAuthenticated : [Guest, LoggedIn Str] -> Result {} [Unauthorized] isAuthenticated = \user -> if user == Guest then Err Unauthorized else Ok {}
The function isAuthenticated
will take the user, and if they are a Guest
return an Err Unauthorized
. In our controller, we pipe this into Task.fromResult!
which will short-circuit to our top-level handler.
Models.Session.isAuthenticated session.user |> Task.fromResult! # ... continue to handle sensitive routes
Any Err Unauthorized
will be caught in our top-level error handler and transformed into an unauthorized HTML page response.
main : Request -> Task Response [] main = \req -> Task.onErr (handleReq req) \err -> when err is Unauthorized -> Views.Unauthorised.page {} |> respondHtml [] # ... handle other errors like BadRequest, NotFound, etc.
GET '/'—List BigTasks
The first route on our BigTask controller returns our data table.
We start by parsing all of the relevant query parameters from the URL so we can use them to filter, sort, and paginate the data.
# src/Controllers/BigTask.roc queryParams = req.url |> parseQueryParams |> Result.withDefault (Dict.empty {}) items = queryParams |> Dict.get "updateItemsPerPage" |> Result.try Str.toI64 |> Result.onErr \_ -> queryParams |> Dict.get "items" |> Result.try Str.toI64 |> Result.withDefault 25 page = queryParams |> Dict.get "page" |> Result.try Str.toI64 |> Result.withDefault 1 sortBy = queryParams |> Dict.get "sortBy" |> Result.withDefault "ID" sortDirection = when Dict.get queryParams "sortDirection" is Ok "asc" -> ASCENDING Ok "ASC" -> ASCENDING Ok "desc" -> DESCENDING Ok "DESC" -> DESCENDING _ -> ASCENDING
Then we query the SQLite3 database.
tasks = Sql.BigTask.list! { dbPath, page, items, sortBy, sortDirection, } total = Sql.BigTask.total! { dbPath }
Finally, we render a HTML table and add a response header to push a new URL. The URL will include the current page, items per page, sort by, and sort direction that was parsed earlier.
sortDirectionStr = when sortDirection is ASCENDING -> "asc" DESCENDING -> "desc" updateURL = "/bigTask?page=$(Num.toStr page)&items=$(Num.toStr items)&sortBy=$(sortBy)&sortDirection=$(sortDirectionStr)" Views.BigTask.page { session, tasks, sortBy, sortDirection, pagination : { page, items, total, baseHref: "/bigTask?", }, } |> respondHtml [{name : "HX-Push-Url", value : Str.toUtf8 updateURL}]
Views.BigTask.page
This is what it looks like when rendered:
# src/Views/BigTask.roc page = \{ session, tasks, pagination, sortBy, sortDirection } -> Views.Layout.layout { user: session.user, description: "Just making a big table", title: "BigTask", navLinks: Models.NavLinks.navLinks "BigTask", } [ div [class "container-fluid"] [ div [class "row align-items-center justify-content-center"] [ Html.h1 [] [Html.text "Big Task Table"], Html.p [] [text "This table is big and has many tasks, each task is a big task..."], ], div [class "row"] [ div [class "inline-block m-2"] [ a [ type "button", class "btn btn-success", href "/bigTask/downloadCsv", (attribute "download") "", (attribute "hx-disable") "", (attribute "aria-label") "Download Button", ] [ text "Download CSV", ], ], ], div [class "row"] [ # render the data table from a description of the columns and the tasks Views.Bootstrap.renderDataTable (columns { sortBy, sortDirection }) tasks ], div [class "row"] [ # render pagination buttons to navigate the table paginationView { page: pagination.page, items: pagination.items, total: pagination.total, baseHref: pagination.baseHref, rowCount: Num.toU64 (tasks |> List.map (\_ -> 1) |> List.sum), startRow: Num.toU64 (((pagination.page - 1) * pagination.items) + 1), }, ], ], ]
The following is our download button. We use a link with hx-disable
to prevent htmx from intercepting the response which would stop the browser from downloading the file.
a [ type "button", class "btn btn-success", href "/bigTask/downloadCsv", (attribute "download") "", (attribute "hx-disable") "", (attribute "aria-label") "Download Button", ] [ text "Download CSV", ],
Data Table Inputs
How each cell in the data table is rendered is determined by a description of the column. For our table, we created a helper and provided the sortBy
and sortDirection
values to vary the description for our sorted column.
columns : { sortBy : Str, sortDirection : [ASCENDING, DESCENDING], } -> List (DataTableColumn BigTask)
For example the first column "Reference ID"
is the most simple, it displays the referenceId
field of the task, does not display a sorting icon, and its width is not specified.
{ label: "Reference ID", name: "ReferenceID", sorted: None, renderValueFn: \task -> Html.text task.referenceId, width: None, }
The renderValueFn
takes a row of data (a BigTask) and returns a Html.Node
to display as a single cell in the table. Because we have provided the type annotation List (DataTableColumn BigTask)
the compiler can verify that we are correctly using the task
and provide helpful error messages.
The second column "Customer ID"
is more complex, it displays a DataTableForm
input element to modify the customerReferenceId
field of the task. It also displays a sorting icon button to toggle sorting the table by this column.
{ label: "Customer ID", name: "CustomerReferenceID", sorted: sortedArg "CustomerReferenceID", width: None, renderValueFn: \task -> idStr = Num.toStr task.id { updateUrl: "/bigTask/customerId/$(idStr)", inputs: [ { name: "CustomerReferenceID", id: "customer-id-$(idStr)", value: Text task.customerReferenceId, validation: None, }, ], } |> Views.Bootstrap.newDataTableForm |> Views.Bootstrap.renderDataTableForm, }
Views.Bootstrap.renderDataTable
The DataTableForm
type is used to render a form with inputs such as text box, date picker, or dropdown selection.
# src/Views/Bootstrap.roc DataTableInputValidation : [None, Valid, Invalid Str] DataTableForm := { updateUrl : Str, inputs : List { name : Str, # the key in form data id : Str, # uniquely identifier, maintain focus between renders value : [Text Str, Date Str, Choice {selected: U64, options: List Str}], validation : DataTableInputValidation, }, }
newDataTableForm = @DataTableForm renderDataTableForm : DataTableForm -> Html.Node renderDataTableForm = \@DataTableForm {updateUrl, inputs} -> renderFormSection = \{name,id,value,validation} -> when value is Text str -> renderTextSection {name,id,str,validation} Date str -> renderDateSection {name,id,str,validation} Choice {selected, options} -> renderChoiceSection {name,id,selected,options,validation} Html.form [ (attribute "hx-put") updateUrl, (attribute "hx-trigger") "input delay:250ms", (attribute "hx-swap") "outerHTML", ] ( inputs |> List.map renderFormSection |> List.join ) renderTextSection = \{name,id,str,validation} -> [ Html.label [Attribute.for id, Attribute.hidden ""] [Html.text name], (Html.element "input") [ Attribute.type "text", class "form-control $(validationClass validation)", Attribute.id id, Attribute.name name, Attribute.value str, ] [], validationMsg validation ]
PUT '/customerID/'—Update CustomerReferenceID
The following shows how we handle an update to the CustomerReferenceID
for a given task. We decode the request body and parse the expected fields.
# src/Controllers/BigTask.roc (Put, ["customerId", idStr]) -> values = decodeFormValues! req.body id = decodeBigTaskId! idStr # either return a I64 or short circuit with a BadRequest validation = Dict.get values "CustomerReferenceID" |> Result.mapErr \_ -> BadRequest (MissingField "CustomerReferenceID") |> Result.try \cridstr -> when Str.toI64 cridstr is Ok i64 if i64 > 0 && i64 < 100000 -> Ok Valid _ -> Ok (Invalid "must be a number between 0 and 100,000") |> Task.fromResult! updateBigTaskOnlyIfValid! validation {dbPath, id, values} { updateUrl : "/bigTask/customerId/$(idStr)", inputs : [{ name : "CustomerReferenceID", id : "customer-id-$(idStr)", value : Text (Dict.get values "CustomerReferenceID" |> Result.withDefault ""), validation, }], } |> Views.Bootstrap.newDataTableForm |> Views.Bootstrap.renderDataTableForm |> respondHtml [] decodeBigTaskId = \idStr -> Str.toI64 idStr |> Result.mapErr \_ -> BadRequest (InvalidBigTaskID idStr "expected a valid 64-bit integer") |> Task.fromResult
Form Input Validaiton
The response from our PUT
request will include the validation state of the input. This is a useful visual indicator to the user that their input is either Valid (displayed in green) and has been successfully saved, or it is Invalid (displayed in red with a message) and has not been saved to the database.
Download CSV Data
The following code example shows the endpoint we use to download the data as a CSV file.
When the server receives GET
request to /bigTask/downloadCsv
, it responds with our data in the response body; the content type, disposition, and length headers.
(Get, ["downloadCsv"]) -> data = """ ID, CustomerReferenceID, DateCreated, Status 1, 12345, 2021-01-01, Raised 2, 67890, 2021-01-02, Completed 3, 54321, 2021-01-03, Deferred """ |> Str.toUtf8 Task.ok { status: 200, headers: [ { name: "Content-Type", value: Str.toUtf8 "text/plain" }, { name: "Content-Disposition", value: Str.toUtf8 "attachment; filename=table.csv" }, { name: "Content-Length", value: Str.toUtf8 "$(List.len data |> Num.toStr)" }, ], body: data, }
In this example, we hard-coded the CSV data. A more realistic example might query the database, and use an encoder to convert to the desired format. For example, Encode.toBytes tasks Json.utf8
would encode the list of tasks as JSON data.
Client-side State Management
The state of the BigTask table is managed using URL query parameters.
For example, the following URL encodes the current page number, the number of items per page, and which column and direction to sort by.
/bigTask?page=1&items=5&sortBy=ID&sortDirection=asc
Keeping the state for our table in the URL has some nice advantages.
First, navigating to the same URL provides the same view.
Second, any changes can be saved in browser history which means the "Back" and "Forward" buttons navigate through the state and work as expected.
To achieve this there were a couple of features of htmx and the browser used. Both of these can be seen in the example below.
Html.form [ (attribute "hx-get") "", # reload the current URL, including the curent parameters (attribute "hx-trigger") "input delay:500ms", (attribute "hx-target") "body", (attribute "hx-swap") "outerHTML", (attribute "id") "formUpdateItemsPerPage", ] [ (element "input") [ Attribute.type "number", Attribute.name "updateItemsPerPage", class "form-control", (attribute "id") "updateItemsPerPage", Attribute.value "$(Num.toStr currItemsPerPage)", Attribute.min "$(Num.toStr minItemsPerPage)", Attribute.max "$(Num.toStr maxItemsPerPage)", styles [ "border-top-right-radius: 5px;", "border-bottom-right-radius: 5px;", ], ] [] ],
The form is submitted using the hx-get
attribute with an empty URL ""
. This is a standard browser behaviour to reference the current document and so htmx will send its query using the current URL and parameters along with the form values.
The form includes the value updateItemsPerPage
which instructs our server to update the number of items per page.
As we saw earlier, when we query for the table; first we parse this value from the query parameters, and only if it is not present do we use the value of items
or a default.
items = queryParams |> Dict.get "updateItemsPerPage" |> Result.try Str.toI64 |> Result.onErr \_ -> queryParams |> Dict.get "items" |> Result.try Str.toI64 |> Result.withDefault 25
Final thoughts
In this article, I've shared some of the things I learnt while building a data table using htmx and roc. I hope you find it useful and are inspired to try it out for yourself.
I look forward to future experiments with htmx and roc and hope to share more in the future.