Project 5: Crypto (part 2)

Project 5: Crypto (part 2)

June 28 2022

This is part two of building a Crypto tracker project using Django, HTMX, and hyperscript. In Part 1, we went through building a complex form via Django forms. Adding hyperscript to guide our user entries, using halfmoon toasts, as well as pagination with click to add more UX.

It is recommended that you go through the previous tutorials in this series as well as part 1.

Here are the user stories for part 2:

  1. I want to display the price in Crypto per Fiat format or Fiat per Crypto format
  2. I want to be able to edit my form inline
  3. I want to be able to delete my transaction with a confirmation box
  4. I want to mark my transactions as taxable or non-taxable events

So let's get started!


Alternative Price

At first glance, some of our prices seem broken. For example, a test transaction where a user sold 1 BTC for 65650 EUR, displays as "-0.000015232 BTC per 1 EUR". This is technically correct, but most users would be interested in how many Euros it is per BTC, not how we displayed it.

Now - the ideal fix for this is to make a currency model where you label each currency as fiat or crypto and always display the price in fiat. Then, you will have to think about how to label crypto to crypto transactions. It can get complicated quickly.

Since we are using this project as an opportunity to learn htmx and hyperscript. We would solve this problem in a simpler way. By allowing the user to toggle between the two different prices using hyperscript.

First, we will add a method to our model to calculate the alternative price.

#in crypto/models.py
#nothing else changed
 def alt_price(self):
        price = (self.bought_currency_amount + self.bought_currency_fee) / (
            self.sold_currency_amount + self.sold_currency_fee
        )
        return price

    def save(self, *args, **kwargs):
        if not self.bought_currency_fee:
            self.bought_currency_fee = 0
        if not self.sold_currency_fee:
            self.sold_currency_fee = 0

        self.price = (self.sold_currency_amount + self.sold_currency_fee) / (
            self.bought_currency_amount + self.bought_currency_fee
        )

Next, we will go ahead and put the alternative price in our transactions template with a hidden display.

<div class="col-3">

    <p id="default-price-{{transaction.pk}}" class="d-block">
    {{transaction.price|floatformat:9}} 
    {{transaction.sold_currency}} per 1 {{transaction.bought_currency}} </p>
    
    <p id="alt-price-{{transaction.pk}}" class="d-none">
    {{transaction.alt_price|floatformat:9}} 
    {{transaction.bought_currency}} per 1 {{transaction.sold_currency}} </p>
    </div>

Note that we have given each of our prices an id with the transaction primary key. That will allow our hyperscript command to know which <p> to hide and which to show.

Finally, the last step is to add our hyperscript command.

 <div class="col-3">

    <p id="default-price-{{transaction.pk}}" class="d-block">
    {{transaction.price|floatformat:9}} {{transaction.sold_currency}} per 1 {{transaction.bought_currency}} </p>
    <p id="alt-price-{{transaction.pk}}" class="d-none">
    {{transaction.alt_price|floatformat:9}} {{transaction.bought_currency}} per 1 {{transaction.sold_currency}} </p>
    <button 
    class="btn"
    _ = "on click 
          toggle between .d-none and .d-block on #alt-price-{{transaction.pk}} 
          then toggle between .d-none and .d-block on #default-price-{{transaction.pk}}" 
    > Flip the price </button>
    </div>

That's it. Our feature is now complete!

Inline Editing

Our user story calls for the ability to be able to edit transactions inline. The big picture approach here is to use HTMX to turn our transaction row into a form via a GET request to the server. Then, when the user makes his edits and submits the form - we use HTMX again to replace the form with the edited transaction.

So, here is what we need

  1. An inline form component that replaces our transaction row on a GET request
  2. A single transaction component to return on the form POST request
  3. The URL endpoint and the view to handle both requests

To start, let's refactor our transactions.html component. We will put the transaction loop into a single transaction component. In other words, we will cut/paste our transaction row from transactions.html and put it in its own component.

First, let's make the new component

touch templates/components/single_transaction.html

Then, we will cut this part and put it in the new component.

<div class="card row"> 
    <div class="col-3">
    <p> Transacation date: {{transaction.date}} </p>
    {% if transaction.exchange %}  <small> Exchange: {{transaction.exchange}} </small> {% endif %}
    </div>

     <div class="col-3">
    <p> {{transaction.sold_currency_amount|floatformat:2}} {{transaction.sold_currency}} </p>
    {% if transaction.sold_currency_fee %}  <small> Fees: {{transaction.sold_currency_fee|floatformat:2}} {{transaction.sold_currency}}</small>  {% endif %}
    </div>

    <div class="col-3">
    <p>{{transaction.bought_currency_amount|floatformat:2}} {{transaction.bought_currency}} </p>
    {% if transaction.bought_currency_fee %} <small> {{transaction.bought_currency_fee|floatformat:2}} {{transaction.bought_currency}}  </small> {% endif %}
    </div>

     <div class="col-3">

    <p id="default-price-{{transaction.pk}}" class="d-block">
    {{transaction.price|floatformat:9}} {{transaction.sold_currency}} per 1 {{transaction.bought_currency}} </p>
    <p id="alt-price-{{transaction.pk}}" class="d-none">
    {{transaction.alt_price|floatformat:9}} {{transaction.bought_currency}} per 1 {{transaction.sold_currency}} </p>
    <button 
    class="btn"
    _ = "on click 
            toggle between .d-none and .d-block on #alt-price-{{transaction.pk}} 
            then toggle between .d-none and .d-block on #default-price-{{transaction.pk}}" 
    > Flip the price </button>
    </div>
</div>
       

Next, let's go ahead and update our transactions.html component.

<!-- in transactions.html -->
{% for transaction in transactions %}
    
    {% include 'components/single_transaction.html'%} <!-- new -->
    {% empty %}
        <div class="row card">
            <p> No transactions yet </p>
        </div>
    {% endfor %}

    {% if transactions.has_next %}
    <button
        class= "btn btn-primary"
        hx-get="/transactions?page={{ transactions.next_page_number }}"
        hx-swap="outerHTML"
        >
        Load More
            <p class="htmx-indicator"> Loading... </p>
</button>
    {% endif %}

We know that we will replace our single transaction component with an inline form. So, let's go ahead and put our HTMX attributes in our single_transaction component.

<!-- in single_transaction.html -->
<div class="card row" hx-swap="outerHTML" hx-target="this">  <!-- new -->
    <div class="col-3">
        <p> Transacation date: {{transaction.date}} </p>
        {% if transaction.exchange %}  
        <small> Exchange: {{transaction.exchange}} </small> 
        {% endif %}
        <p> <a hx-get="{% url 'inline_form' transaction.pk %}"> Edit </a> </p> <!-- new --> 
    </div>
<!--
hx-swap and target inherits from parent
hx-trigger is click for <a> tags by default 
-->

Next we need to build our URL endpoint, view, and our inline form.

# crypto/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("currencies/<str:input_clicked>", views.currencies, name="currencies"),
    path("transactions", views.transactions, name="transactions"),
    path("inline-form/<int:transaction_pk>", views.inline_form, name="inline_form"), #new
]

Here is our view. One of the advantages of Django forms, is we can reuse our form here without writing any extra code. We will simply rearrange the fields on the frontend.

def inline_form(request, transaction_pk):
    transaction = get_object_or_404(Transaction, pk=transaction_pk)
    inline_form = TransactionForm(instance=transaction)
    return render(
        request,
        "components/inline_transaction_form.html",
        {"inline_form": inline_form, "transaction": transaction},
    )

Note that we used a different name than the generic "form" here. Since our home page has already a "form" - when we worked on logging new transactions.

To see our new form, we need our "inline_transaction_form" component. We want our new form to look similar to our transaction card, so the CSS classes are similar to our single_transaction.html component.

<!-- in inline_transaction_form.html -->
{% load crispy_forms_tags %}

<div class="card row" hx-swap="outerHTML" hx-target="this"> 
    <form hx-post= "{% url 'inline_form' transaction.pk %}">
        
        {% if errors %} {{errors}} {% endif %} <!-- not all our fields are cripsy, so we need to render the errors -->
        <div class="form-row row-eq-spacing-sm">
    
            <div class="col-3">
                <p> {{inline_form.date|as_crispy_field}} </p>
                <p> {{inline_form.exchange|as_crispy_field}} </P>
            </div>

            <div class="col-3">
        
                <div class="input-group m-20">
                    <label class="required font-weight-bold">Paid / Sent / Withdrawn: </label> 
                        {{inline_form.sold_currency_amount}} 
                    <div class="input-group-append">
                        <span class="input-group-text">{{transaction.sold_currency}}</span>
                    </div>
                    <div class= "d-none"> {{inline_form.sold_currency}} </div>
                </div>

                <div class="input-group m-20">
                    <label class="required font-weight-bold">Bought / Received / Deposited</label>
                    {{inline_form.bought_currency_amount}} 
           
                <div class="input-group-append">
                    <span class="input-group-text">{{transaction.bought_currency}}</span>
                </div>
                <div class= "d-none"> {{inline_form.bought_currency}} </div>
                </div>
            </div>

            <div class="col-3">
                <div class="input-group m-20">
                    <label> Fees on your sold currency (if any): </label> 
                    {{inline_form.sold_currency_fee}} 
                    <div class="input-group-append">
                        <span class="input-group-text">{{transaction.sold_currency}} </span>
                    </div>
                </div>

                <div class="input-group m-20">
                    <label> Fees on your bought currency (if any): </label> 
                    {{inline_form.bought_currency_fee}} 
                    <div class="input-group-append">
                        <span class="input-group-text">{{transaction.bought_currency}} </span>
                    </div>
                </div>
                <div class= "text-right"> 
                    <button class= "btn btn-primary" > Submit  </button>
                </div>
            </div>
   
        </div>
    </form>
</div>

Note, we are not giving access to our user to change the currency, rather putting it as a read-only input appended text. The actual currency inputs are hidden.

Let's go ahead and make sure that our GET requests are working correctly. Click the edit button. If everything is in place, the transaction should be replaced by an inline form, holding the same values.

Lastly, we will go ahead and handle the form submission in our view.

def inline_form(request, transaction_pk):
    transaction = get_object_or_404(Transaction, pk=transaction_pk)
    inline_form = TransactionForm(instance=transaction)
    #new
    if request.method == "POST":
        form = TransactionForm(request.POST, instance=transaction)
        if form.is_valid():
            transaction = form.save()
            return render(
                request,
                "components/single_transaction.html",
                {"transaction": transaction},
            )
        else:
            return render(
                request,
                "components/inline_transaction_form.html",
                {
                    "inline_form": inline_form,
                    "transaction": transaction,
                    "errors": form.errors,
                },
            )
    #end new
    return render(
        request,
        "components/inline_transaction_form.html",
        {"inline_form": inline_form, "transaction": transaction},
    )

Now, let's go ahead and test our new functionality. We should see something like this.

That's it! our user story is done. Go ahead and commit your code. You can see my version of the project here.

Delete

Our next task is to delete our transaction with a confirmation. In a previous chapter, we went through an implementation of a vanilla hx-confirm using the browser confirmation dialog. This time, we would use an external library (sweetalert2) for a better UX.

First, let's add the CDN to our base template.

<!-- in base.html -->
 <!-- sweetalert2 -->
    <script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script>

Then, let's go ahead and build our view and URL. Big picture, we are deleting via a hx-delete request and returning back our transactions table.

Here is our view,

from django.views.decorators.http import require_POST, require_http_methods

@require_http_methods(["DELETE"])
def delete(request, transaction_pk):
    transaction = get_object_or_404(Transaction, pk=transaction_pk)
    transaction.delete()
    return redirect("transactions")

And this our endpoint,

urlpatterns = [
  ...
    path("delete/<int:transaction_pk>", views.delete, name="delete"),
]

Last part is our frontend. We need a "delete" button that hits our endpoint with a delete request. To keep our transaction component clean, let's go ahead and make it into its own component as well.

touch templates/components/delete_button.html

<!-- in delete_button.html -->
  <div class="position-absolute bottom-0 bottom-sm-auto top-sm-0 right-0"> 
    <button 
    class="btn btn-danger btm m-10" 
    type="button"
    hx-delete= "{% url 'delete' transaction.pk %}"
    hx-trigger = 'confirmed'
    hx-target = "#transactions"
     _="on click
             call Swal.fire({ 
                title: 'Are you sure?',
                text: 'You will not be able to reverse this!',
                icon: 'warning',
                showCancelButton: true,
                confirmButtonText: 'Yes, delete it!',
                cancelButtonColor: '#d33',
                confirmButtonColor: '#3085d6',
                heightAuto: false
            })
             if result.isConfirmed trigger confirmed then call halfmoon.toastAlert('alert-2', 3500)">
    Delete
    </button>
  </div>

Here is what we are doing with htmx and hyperscript. Our hx-delete hits our delete endpoint when it gets a special trigger called "confirmed". The response targets the #transactions DOM element.

Then, using hyperscript we fire a customized alert box (see docs for sweetalert2 here). Once the user confirms via the sweetalert2 alert box, we trigger our htmx event then a halfmoon toast.

Before trying it out, let's build our halfmoon delete alert and fix our CSS, so our button shows up in the right place.

In our alert.html components, let's add this,

<div class="alert alert-danger filled" role="alert" id="alert-2">
<p> Transacation deleted successfully </p>      
</div>

Then, let's add the needed CSS classes. Again, to make things clean, we will put the flip price div in its own component.

touch templates/components/flip_price_button.html

<!-- cut & paste from single_transaction.html to flip_price_button.html -->
   <div class="col-3 position-relative"> <!-- new -->
    <p id="default-price-{{transaction.pk}}" class="d-block">
    {{transaction.price|floatformat:9}} {{transaction.sold_currency}} per 1 {{transaction.bought_currency}} </p>
    <p id="alt-price-{{transaction.pk}}" class="d-none">
    {{transaction.alt_price|floatformat:9}} {{transaction.bought_currency}} per 1 {{transaction.sold_currency}} </p>
    <button 
    class="btn"
    _ = "on click 
           toggle between .d-none and .d-block on #alt-price-{{transaction.pk}} 
           then toggle between .d-none and .d-block on #default-price-{{transaction.pk}}" 
    > Flip the price </button>
    </div>

Finally, we will include our two new components in our single_transaction component.

<!-- in single_transaction.html -->
    {% include 'components/flip_price_button.html' %}
    {% include 'components/delete_button.html' %}

Now, you can try it out. You should see this.

Our user story is now done. Go ahead and commit your code. You can see my version here.

Mark Taxable

Our next user story would be to allow our user to mark transactions as taxable events. We are using this functionality to see an implementation of the htmx bulk update UX pattern.

The big picture approach here is

  1. We add a "taxable" checkbox to each of our transaction rows
  2. Then we wrap our transactions rows with a form element  
  3. Lastly, since we have a form with checkboxes, we need a DOM element with the right htmx attributes to post that form.

On submission, we will get the values of the checked boxes to a view that will process the data and mark them taxable or non-taxable.

To post our form with an element that is outside it, we are going to use an htmx attribute called "hx-include". Once the values of the checkboxes go to the backend, we update the database with the updated taxable status.

First, let's go ahead and build our checkbox component.

touch templates/components/taxable_checkbox.html

<!-- in taxable_checkbox.html -->
    <div class="position-absolute bottom-0 right-0"> 
        <div class="custom-checkbox">
         <input type="checkbox" id="checkbox-tax-{{forloop.counter}}" value="{{transaction.pk}}" name="transaction_tax">
         <label for="checkbox-tax-{{forloop.counter}}"> Change Tax Status</label>
        </div>
  </div>

Note - we have multiple checkboxes on the page. We are using the forloop.counter tag to give them different ids so checking them works.

Next, we will include our new component in our single_transaction component.

    {% include 'components/flip_price_button.html' %}
    {% include 'components/delete_button.html' %}
    {% include 'components/taxable_checkbox.html' %} <!-- new -->

You should now see - a checkbox at the bottom of each transaction row.

The next steps is to work on our backend to make the checkbox functional.

First, we will add a new field to our database.

# in models.py
class Transaction(models.Model):
    ...
    taxable = models.BooleanField(default=True)

I made the default True as a way to test the functionality quickly - since I had a lot of previous test transactions. Also, let's not forget to migrate.

docker compose exec web python manage.py makemigrations
docker compose exec web python manage.py migrate

Next, we need an URL endpoint.

urlpatterns = [
   ...
    path("delete/<int:transaction_pk>", views.delete, name="delete"),
    path("taxable", views.taxable, name="taxable"), #new
]

And, let's put a hold on our view for now. We will come back to it once we add our htmx attributes.

def taxable(request):
    pass

Again, to review, our approach is to wrap a form around our transaction rows with the checkboxes. Then, we add a DOM element outside that form that "submits" the form.

Here is the form around the transactions rows. It will be in our home template.

<!-- in home.html -->
<form id= "taxable-form">
    <div class= "content" id="transactions" 
    hx-trigger= "load"
    hx-get = "{% url 'transactions' %}"
    >
    <p class="htmx-indicator"> Loading... </p>

    </div>
</form>

Then in our transactions.html component, we will add a new DOM element to submit our form.

<!-- in transactions.html -->
<div class="row justify-content-between">  <!-- new class -->
    {% if transactions.has_next %}
    <button
        class= "btn btn-primary"
        hx-get="/transactions?page={{ transactions.next_page_number }}"
        hx-swap="outerHTML"
        >
        Load More
            <p class="htmx-indicator"> Loading... </p>
    </button>
    {% endif %}
    
    <!-- new -->
    <div hx-include="#taxable-form" hx-target="#transactions">
  <a class="btn" hx-post="{% url 'taxable' %}"> Change Taxable Status </a>
    </div>
</div>

Lastly we need indicate if a transaction is taxable or not as part of each transaction row to our user. So, let's update our single_transaction component as such,

...
  <p> Transacation date: {{transaction.date}} </p>
    {% if transaction.exchange %}  <small> Exchange: {{transaction.exchange}} </small> {% endif %}
    {% if transaction.taxable %} <p> <mark> Taxable event </mark> </p> {% endif %} <!-- new -->

When our user clicks the <a> tag, we will post a form with the checkbox data to our backend via hx-include.

Our view is then going to process that data and update the database - returning a new transaction list.

Here is that view

@require_POST
def taxable(request):
    transactions = request.POST.getlist("transaction_tax")
    query_set = []
    for item in transactions:
        transaction = Transaction.objects.get(pk=item)
        if transaction.taxable:
            transaction.taxable = False
        else:
            transaction.taxable = True
        # could do transaction.save() here, but bulk_update is faster
        query_set.append(transaction)
    Transaction.objects.bulk_update(query_set, ["taxable"])
    return redirect("transactions")

Note, all our checkboxes have the same name "transaction_tax" - so, that's why the POST.getlist works.

That's it. Our user story is now done. Go ahead and test it, you should see something like this.

Our project is now complete. You can see the entire project here.

Conclusion

In this second part of our crypto tracker project, we used htmx, Django, and hyperscript to build a crypto tracker. We implemented several advanced UX patterns without writing any JavaScript. Those patterns are button display toggling, inline form editing, deleting using the sweetalert2 library, and bulk updating.

Get notified about new HTMX/Django tutorials
You will only get an email whenever a post or a course is published!

Copyright © HTMX-Django 2022.

Built by Jonathan Adly. Contact for opportunities.