-
Notifications
You must be signed in to change notification settings - Fork 1
Developer Doc ‐ HTMX Patterns
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.
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.
In this example we'll step though the data load cycle using the loading of MS Excel based plankton data.
Starting with the HTML page we're looking at the body of the Load Sample from file card
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
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"),
]
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
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 elementid="div_id_message"
is located
The HTML displayed to the user looks like this on the page:
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)
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