Skip to content

Developer Doc ‐ HTMX Patterns

Patrick Upson edited this page Jul 8, 2024 · 6 revisions

HTMX

Dart makes use of the HTMX API to call python functions and replace HTML elements on a page with HTML returned from the python function. This documentation will describe HTMX patterns for how Dart implements function calls, but will not go into depth on how to use HTMX.

HTMX - Data Loading Cycle

When loading data to a page the element (page or input) making the request should not directly call the function to load the data itself. Instead the element should make a request using hx-get (or hx-post if a file is involved) to a url that will return a core.forms.save_load_component() component. The component will contain an additional hx-get or hx-post call that will then call the function to request data. In the meantime a loading dialog will be displayed on the page for the user to look at (it's better than watching paint dry). Once the data has been compiled on the server, it can be sent back and swapped on to the page.

Page loads -> HTMX request is sent -> function returns loading alert -> loading alert sends HTMX request -> function returns data

sequenceDiagram
 Browser->>Server: hx-get request
 Server->>Function Call: Request and parameters
 Function Call->>Server: HTML for save/load component with hx-post call embedded
 Server->>Browser: HTML for save/load component
 Browser->>Browser: Update page with HTML for save/load component
 Browser->>Server: Open Websocket
 Browser->>Server: hx-post request
 Server->>Function Call: Request and parameters
 Function Call->>Function Call: Do Work, notify server of work done through websocket
 Function Call->>Server: Return HTML for success/failure message component
 Server->>Browser: HTML for success/failure message component
 Browser->>Browser: Update/Replace HTML for save/load component with HTML for success/failure message.
Loading

Example of loading data from a Plankton excel file

In this example we'll step though the data load cycle using the loading of MS Excel based plankton data.

Page Loads

Starting with the HTML page we're looking at the body of the Load Sample from file card image

From core/templates/core/mission_plankton.html

<form id="form_id_plankton_upload" hx-encoding="multipart/form-data" onkeydown="return event.key != 'Enter';">
    <input type="hidden" name="mission_id" value="{{ mission.pk }}" />
    <div class="row">
        <div class="col">
            {# choose file input #}
            <input id="id_input_sample_file" class="form-control form-control-sm"
                {# for now plankton comes from an xls file so only allow xls types. #}
                type="file" name="plankton_file" accept=".xls,.xlsx,.xlsm"
                hx-trigger="change"
                hx-get="{% url 'core:mission_plankton_load_plankton' mission.pk %}"
                hx-swap="none"
            />
        </div>
    </div>
    <div id="div_id_message" class="row"></div>
    <div id="div_id_plankton_form" class="row"></div>
</form>
<form id="form_id_plankton_upload" hx-encoding="multipart/form-data" onkeydown="return event.key != 'Enter';">

Start by setting up the form using hx-encoding to indicate we're sending a file and disable submitting the form if they user presses the enter key

    <input type="hidden" name="mission_id" value="{{ mission.pk }}" />

Create a hidden input to hold the value that will be passed to the server to indicate what the mission ID we're interacting with is.

    <div class="row">
        <div class="col">
            {# choose file input #}

Layout the input and 'choose file' button as a row.

            <input id="id_input_sample_file" class="form-control form-control-sm"
                type="file" name="plankton_file" accept=".xls,.xlsx,.xlsm"

This is the standard code for creating an HTML File Chooser input field that accepts .xls type files

                hx-trigger="change"

When the value of the input dialog changes, trigger the request

                hx-get="{% url 'core:mission_plankton_load_plankton' mission.pk %}"

When triggered, call the function associated with the url 'core:mission_plankton_load_plankton'

                hx-swap="none"

When the function returns we don't want to override this component with the response so set the hx-swap to none, which means do nothing with the response. Instead, the HTML for the save/load component will use hx-swap-oob (swap out of band) to replace another component somewhere else on the page.

            />
        </div>
    </div>

close the layout for the HTML row containing the choose file input and button

    <div id="div_id_message" class="row"></div>

The response from the hx-get request will return HTML for a save/load component that will have an hx-swap-oob pointing to #div_id_message which will place the html for the save/load component inside this row.

    <div id="div_id_plankton_form" class="row"></div>

This is an area to display data from the file to the user so they know what is being loaded.

</form>

close the form element

HTMX request is sent

When the user clicks on the input element and selects a file to load an HTMX GET request is sent to the specified URL. The URL definition is located in the core/views_mision_plankton.py module and points to the load_plankton function.

From 'core/views_mission_plankton.py`

plankton_urls = [
    path('plankton/<int:pk>/', PlanktonDetails.as_view(), name="mission_plankton_plankton_details"),
    path('plankton/load/<int:mission_id>/', load_plankton, name="mission_plankton_load_plankton"),  # <-- URL being called
    path('plankton/import/<int:mission_id>/', import_plankton, name="mission_plankton_import_plankton"),
    path('plankton/list/<int:mission_id>/', list_plankton, name="mission_plankton_list_plankton"),
    path('plankton/db/<int:mission_id>/', get_plankton_db_card, name="mission_plankton_get_plankton_db_card"),
    path('plankton/biochem/', upload_plankton, name="mission_plankton_biochem_upload_plankton"),
]

Function returns loading alert

In the first part of the function the request received is a 'GET' request, which will create a save_load_component, to indicate to the user something is happening.

def load_plankton(request, **kwargs):
    mission_id = kwargs['mission_id']

    if request.method == 'GET':
        # you can only get the file though a POST request
        url = reverse_lazy('core:mission_plankton_load_plankton', args=(mission_id,))
        attrs = {
            'component_id': 'div_id_message',
            'message': _("Loading"),
            'alert_type': 'info',
            'hx-trigger': "load",
            'hx-swap-oob': 'true',
            'hx-post': url,
        }
        load_card = forms.save_load_component(**attrs)
        return HttpResponse(load_card)
    elif request.method == 'POST':
  • Create the save_load_component with the id 'div_id_message', which is an HTML element noted above that is already on the page
  • hx-trigger: make a request when this components is loaded onto the user's page
  • hx-swap-oob: swap this component onto the page where there is an existing 'div_id_message' element
  • hx-post: once triggered, this component should call the provided url as a POST request. The URL provided points the same function the hx-get request called, which is this function. Only next time we get here the request.method will be a 'POST' request

Loading alert sends HTMX request

The hx-get request returns an HTTP Response contains the following HTML, which will replace the <div id="div_id_message" class="row"></div> element on the user's page.

<div alert_type="info" hx-post="/en/core/plankton/load/24/" hx-swap-oob="true" hx-trigger="load" id="div_id_message">
    <div class="alert alert-info mt-2">
        <div id="div_id_message_message">Loading</div>
        <div class="progress" id="progress_bar">
            <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%"></div>
        </div>
    </div>
</div>

The term alert refers to a Bootstrap styled element, but the element contains HTMX information to initiate the next part of the loading process.

  • hx-trigger="load" - Trigger a new the HTMX request as soon as this element is swapped onto the user's page
  • hx-post=[url] - this next call will be an HTMX POST request to the specified URL
  • hx-swap-oob="true" - The element, when received by a webpage, should be placed on the page where the id for this element id="div_id_message" is located

The HTML displayed to the user looks like this on the page: image

Function returns data

Similarly to the first HTMX Request, this call calls the same function

plankton_urls = [
    path('plankton/<int:pk>/', PlanktonDetails.as_view(), name="mission_plankton_plankton_details"),
    path('plankton/load/<int:mission_id>/', load_plankton, name="mission_plankton_load_plankton"),  # <-- URL being called
    path('plankton/import/<int:mission_id>/', import_plankton, name="mission_plankton_import_plankton"),
    path('plankton/list/<int:mission_id>/', list_plankton, name="mission_plankton_list_plankton"),
    path('plankton/db/<int:mission_id>/', get_plankton_db_card, name="mission_plankton_get_plankton_db_card"),
    path('plankton/biochem/', upload_plankton, name="mission_plankton_biochem_upload_plankton"),
]

Except this time the function is called as a POST request and contains the excel file data, which is read into a Pandas DataFrame. The function then constructs a form showing the user options and then sends the form back to be displayed on the page where further actions can be taken.

def load_plankton(request, **kwargs):
    mission_id = kwargs['mission_id']

    if request.method == 'GET':
       # request.method == POST this time
    elif request.method == 'POST':
        soup = BeautifulSoup('', 'html.parser')

        message_div = soup.new_tag('div')
        message_div.attrs['class'] = "mt-2"
        message_div.attrs['id'] = "div_id_message"
        message_div.attrs['hx-swap-oob'] = "true"
        soup.append(message_div)

        form_div = soup.new_tag('div')
        form_div.attrs['class'] = "row"
        form_div.attrs['id'] = "div_id_plankton_form"
        form_div.attrs['hx-swap-oob'] = "true"
        soup.append(form_div)

        attrs = {
            'component_id': 'div_id_message_alert',
            'message': _("Success"),
            'alert_type': 'success',
            'hx-swap-oob': 'true',
        }
        if 'plankton_file' not in request.FILES:
            attrs['message'] = 'No file chosen'
            attrs['alert_type'] = 'warning'
            post_card = forms.blank_alert(**attrs)
            message_div.append(post_card)

            return HttpResponse(soup)

        file = request.FILES['plankton_file']

        #determine the file type
        debug_logger.debug(file)

        # the file can only be read once per request
        data = file.read()
        file_type: str = file.name.split('.')[-1].lower()

        if file_type.startswith('xls'):
            debug_logger.debug("Excel format detected")

            # because this is an excel format, we now need to know what tab and line the header
            # appears on to figure out if this is zoo or phyto plankton
            tab = int(request.POST['tab'] if 'tab' in request.POST else 1)
            tab = 1 if tab <= 0 else tab

            header = int(request.POST['header'] if 'header' in request.POST else -1)
            dict_vals = request.POST.copy()
            dict_vals['tab'] = tab
            dict_vals['header'] = header

            try:
                dataframe = get_excel_dataframe(stream=data, sheet_number=(tab-1), header_row=(header-1))
                start = dataframe.index.start if hasattr(dataframe.index, 'start') else 0
                dict_vals['header'] = max(start + 1, header)

                # If the file contains a 'What_was_it' column, then this is a zooplankton file.
                # problem is the column may be uppercase, lowercase, may be a mix, may contain spaces or
                # underscores and may or may not end with a question mark. It very typically is the last column,
                # unless a 'comment' column is present.

                table_html = dataframe.head(10).to_html()
                table_soup = BeautifulSoup(table_html, 'html.parser')
                table = table_soup.find('table')
                table.attrs['class'] = "table table-striped"

                table_div = soup.new_tag('div')
                table_div.attrs['class'] = 'vertical-scrollbar'
                table_div.append(table)
            except ValueError as e:
                logger.exception(e)
                attrs = {
                    'component_id': "div_id_plankton_table",
                    'alert_type': "danger",
                    'message': e.args[0]
                }
                table_div = forms.blank_alert(**attrs)

            form = forms.PlanktonForm(dict_vals, mission_id=mission_id)
            form_html = render_crispy_form(form)

            form_soup = BeautifulSoup(form_html, 'html.parser')
            form_soup.append(table_div)

            form_div.append(form_soup)

            return HttpResponse(soup)

HTMX error

If an htmx request returns none due to an error/exception that occurs in the python code the dart2/templates/base.html contains some JavaScript that will replace the element making the htmx call with an alert indicating an unexpected issue occurred and the user should check the error.log file