Creating an interactive event budgeting tool within Wagtail

lb

LB (Ben Johnston)

Posted on October 4, 2022

Creating an interactive event budgeting tool within Wagtail

Wagtail 4.0 has recently been released and it comes with a much nicer design for managing nested InlinePanel (models) or StreamField components.

I thought it might be a good chance to see how we could go about using this new UI to manage more complex data within Wagtail.

This tutorial will walk you through a basic set-up of building an interactive event budgeting tool within the Wagtail page editing interface. While this may not be something that should be done, a spreadsheet may be better, it is a good simple example of nested models along with some JavaScript sprinkles to give the user some instant feedback on their entry.

Goal

  • Build a basic event budgeting tool within the Wagtail page editor.
  • We should be able to enter fixed & variable (per person) prices, along with a sale price and see an estimate of how many tickets will need to be sold to break even.
  • We want the data to be stored in Django models so that we can work with this data server side easily.

Tutorial

0. Getting started

  • This tutorial assumes you have at least done the Wagtail getting started tutorial
  • First, we will create a new Wagtail/Django project and then create an events app within that Django project - python manage.py startapp events
  • Add "events" to INSTALLED_APPS

Versions

  • Python 3.10
  • Wagtail 4.0.2
  • Django 4.1.1
  • Stimulus JS 3.1.0 (Node js is not required for this tutorial)

1. Set up the data Model

  • We will create the initial model and Panels, Wagtail provides a nice abstraction around the Django models and fields with the concept of Panels to provide editing form containers.
  • Wagtail provides an InlinePanel solution that allows nested inline data relations to be edited easily.
  • We will have two core models, the EventPage which extends the Wagtail Page model and contains things like the Page title and the ticket price.
  • Our second model will be a ParentalKey (from modelcluster) relation to the EventPage and be called EventPageBudgetItem, each EventPage can also have an orderable set of these budget item rows. The budget item row will have three fields, the description, amount and whether the price is per person (variable) or fixed.
  • Modify the file events/models.py to add our model, as below.
  • Run python manage.py makemigrations and then python manage.py migrate.
  • Cross-check Confirm you can now go to the Wagtail admin interface, create a new page and then create an Event page with the budget items.
from django import forms
from django.db import models

from modelcluster.fields import ParentalKey

from wagtail.admin.panels import FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel
from wagtail.models import Orderable, Page


class AbstractBudgetItem(models.Model):
    """
    The abstract model for the budget item, complete with panels.
    """

    class PriceType(models.TextChoices):
        PRICE_PER = "PP", "Price per"
        FIXED_PRICE = "FP", "Fixed price"

    description = models.CharField(
        "Description",
        max_length=255,
    )

    price_type = models.CharField(
        "Price type",
        max_length=2,
        choices=PriceType.choices,
        default=PriceType.FIXED_PRICE,
    )

    amount = models.DecimalField(
        "Amount",
        default=0,
        max_digits=6,
        decimal_places=2,
    )

    panels = [
        FieldRowPanel(
            [
                FieldPanel("description"),
                FieldPanel("price_type"),
                FieldPanel("amount"),
            ]
        )
    ]

    class Meta:
        abstract = True


class EventPageBudgetItem(Orderable, AbstractBudgetItem):
    """
    The real model which combines the abstract model, an
    Orderable helper class, and what amounts to a ForeignKey link
    to the model we want to add related links to (EventPage)
    """

    page = ParentalKey(
        "events.EventPage",
        on_delete=models.CASCADE,
        related_name="related_budget_items",
    )


class EventPage(Page):

    ticket_price = models.DecimalField(
        "Price",
        default=0,
        max_digits=6,
        decimal_places=2,
    )

    content_panels = Page.content_panels + [
        MultiFieldPanel(
            [
                InlinePanel("related_budget_items"),
                FieldPanel("ticket_price"),
            ],
            "Budget",
        ),
    ]
Enter fullscreen mode Exit fullscreen mode

Page editing interface with basic fields added

2. Set up the field widgets

  • We will now make the admin interface a bit easier to use and add some data attributes to our fields so we can track them in JavaScript.
  • We will also avoid the type="number" field and make the numbers a bit easier to work with.
  • A note about number fields, Django will use the type="number" by default when you use a Decimal field, to keep things simple we will use a text field with some different attributes. See the UK design system guidelines and a deep dive on why the number input is the worst input.
  • For each of the InlinePanel inner FieldPanels we will add a simple data attribute so that our JavaScript code can be implemented easily without having to know about the field name / id on the elements.
  • For the ticket price field we will use a more specific data attribute "data-budget-target": "ticketPrice", so that it is easier to read this value in our Stimulus js controller (more on that later).
  • Cross-check Once updated, you should be able to see that your number fields look more like text fields and when inspecting the DOM you should be able to see the data-* attributes on each field.
from django import forms
from django.db import models

from modelcluster.fields import ParentalKey

from wagtail.admin.panels import FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel
from wagtail.models import Orderable, Page

# added
NUMBER_FIELD_ATTRS = {
    "inputmode": "numeric",
    "pattern": "[0-9.]*",
    "type": "text",
}


class AbstractBudgetItem(models.Model):
    """
    The abstract model for the budget item, complete with panels.
    """

    class PriceType(models.TextChoices):
        PRICE_PER = "PP", "Price per"
        FIXED_PRICE = "FP", "Fixed price"

    description = models.CharField(
        "Description",
        max_length=255,
    )

    price_type = models.CharField(
        "Price type",
        max_length=2,
        choices=PriceType.choices,
        default=PriceType.FIXED_PRICE,
    )

    amount = models.DecimalField(
        "Amount",
        default=0,
        max_digits=6,
        decimal_places=2,
    )

    panels = [
        FieldRowPanel(
            [
                FieldPanel(
                    "description",
                    # updated - using widget
                    widget=forms.TextInput(attrs={"data-description": ""}),
                ),
                FieldPanel(
                    "price_type",
                    # updated - using widget
                    widget=forms.Select(attrs={"data-type": ""}),
                ),
                FieldPanel(
                    "amount",
                    # updated - using widget
                    widget=forms.TextInput(
                        attrs={"data-amount": "", **NUMBER_FIELD_ATTRS}
                    ),
                ),
            ]
        )
    ]

    class Meta:
        abstract = True


class EventPageBudgetItem(Orderable, AbstractBudgetItem):
    """
    The real model which combines the abstract model, an
    Orderable helper class, and what amounts to a ForeignKey link
    to the model we want to add related links to (EventPage)
    """

    page = ParentalKey(
        "events.EventPage",
        on_delete=models.CASCADE,
        related_name="related_budget_items",
    )


class EventPage(Page):

    ticket_price = models.DecimalField(
        "Price",
        default=0,
        max_digits=6,
        decimal_places=2,
    )

    content_panels = Page.content_panels + [
        MultiFieldPanel(
            [
                InlinePanel("related_budget_items"),
                FieldPanel(
                    "ticket_price",
                    # updated - using widget
                    widget=forms.TextInput(
                        attrs={
                            "data-budget-target": "ticketPrice",
                            **NUMBER_FIELD_ATTRS,
                        }
                    ),
                ),
            ],
            "Budget",
        ),
    ]
Enter fullscreen mode Exit fullscreen mode

Page editing interface with better widgets

3. Set up a custom wrapper Panel container

  • We will need a nice way to add some kind of summary of the totals for the user, there are a few ways to do this. One way could be via the HelpPanel which lets us add arbitrary Django templates to the panel set.
  • However, a nicer way for what we need is to extend the MultiFieldPanel with a custom template and some additional context passed to that template.
  • First, we will create a new BudgetGroupPanel that extends the MultiFieldPanel, in a new file events/panels.py.
  • This file lets us refer to a different template and also inject some extra data into the context.
  • We will use the field_ids so that we can provide a better experience for users with the output element.
# events/panels.py
from django.forms import MultiValueField
from wagtail.admin.panels import MultiFieldPanel


class BudgetGroupPanel(MultiFieldPanel):
    class BoundPanel(MultiFieldPanel.BoundPanel):
        template_name = "events/budget_group_panel.html"

        def get_context_data(self, parent_context=None):
            """
            Prepare a list of ids so that we can reference them in the
            output.
            """

            context = super().get_context_data(parent_context)

            context["field_ids"] = filter(
                None, [child.id_for_label() for child in self.visible_children]
            )

            return context

Enter fullscreen mode Exit fullscreen mode
  • Next, we will need to prepare the template, create a file in your app's templates folder events/templates/events/budget_group_panel.html.
  • This HTML has some data attributes that tell our JavaScript what to attach to, along with when to update.
  • We are using include to include the original Wagtail multi_field_panel template inside our div wrapper.
  • We are using the output element and a suitable h3 title to present the sub-totals and budget estimate to the user.
  • Finally, these budget totals also have data attributes to advise our JavaScript code where to inject the values.
<div
    data-controller="budget"
    data-action="change->budget#updateTotals"
    data-budget-per-price-value="PP"
>
    {% include "wagtailadmin/panels/multi_field_panel.html" %}
    <output for="{{ field_ids|join:' ' }}">
        <h3>Budget summary</h3>
        <dl>
            <dt>Total price per</dt>
            <dd data-budget-target="totalPricePer">-</dd>
            <dt>Total fixed</dt>
            <dd data-budget-target="totalFixed">-</dd>
            <dt>Break even qty</dt>
            <dd data-budget-target="breakEven">-</dd>
        </dl>
    </output>
</div>
Enter fullscreen mode Exit fullscreen mode
  • Finally, we need to use this custom panel in our EventPage model.
  • This will be a simple replacement of the MultiFieldPanel with our BudgetGroupPanel.
  • Cross-check Once updated, you should now be able to see the output content and also check the DOM for the relevant data attributes and for attribute.
# events/models.py
from django import forms
from django.db import models

from modelcluster.fields import ParentalKey

from wagtail.admin.panels import FieldPanel, FieldRowPanel, InlinePanel # updated, removing MultiFieldPanel
from wagtail.models import Orderable, Page

from .panels import BudgetGroupPanel # added

# ... other models

class EventPage(Page):

    ticket_price = models.DecimalField(
        "Price",
        default=0,
        max_digits=6,
        decimal_places=2,
    )

    content_panels = Page.content_panels + [
        BudgetGroupPanel(
            [
                InlinePanel("related_budget_items"),
                FieldPanel(
                    "ticket_price",
                    widget=forms.TextInput(
                        attrs={
                            "data-budget-target": "ticketPrice",
                            **NUMBER_FIELD_ATTRS,
                        }
                    ),
                ),
            ],
            "Budget",
        ),
    ]

Enter fullscreen mode Exit fullscreen mode

Page editing interface with output container

4. Set up JavaScript sprinkles

There are a few good libraries out there that provide ways to add JavaScript 'sprinkles' existing HTML without the need to overhaul your entire system with something like React or Vue. What you use here is an architectural and tooling choice, but the underlying JavaScript that needs to be written is essentially the same.

  • We need a way to load JavaScript code.
  • We need to ensure we can attach the JavaScript listeners/behaviour to the right elements.
  • We need a way to do some JavaScript calculations based on the user's values that are entered, also consider when a user re-orders/removes an inline panel item.
  • We need a way to output the results of these calculations to the DOM at the desired elements.

All of this can be done in React, Vue, Angular, Alpine, jQuery and even JavaScript without any libraries at all. However, Stimulus gives us a nice API that moves the 'JavaScript attaching of behaviour' into the HTML and the 'doing logic stuff' into the JavaScript quite nicely.

You can read more about Stimulus Controllers in their documentation.

If you would like to see this tutorial written with other JavaScript libraries, let me know in the comments and we can explore for comparison.

Now, let's write some JavaScript.

  • Create a new file events/static/js/events.js that will house our Stimulus Controller.
  • We will use the import / from syntax that is available in modern browsers and import the Stimulus library directly from unpkg. For production projects, it would be better to serve this core module from your own project's static files.
  • The controller below will attach to any element that loads with the data-controller="budget" data attribute (we put this in our budget group panel).
  • We wll further refine the JS in the next step, for now let's focus on getting it loading.
import {
    Application,
    Controller,
} from "https://unpkg.com/@hotwired/stimulus@3.1.0/dist/stimulus.js";

class BudgetController extends Controller {
    static targets = [
        "breakEven",
        "ticketPrice",
        "totalFixed",
        "totalPricePer",
    ];
    static values = { perPrice: String };

    connect() {
        this.updateTotals();
    }

    /**
     * Update the DOM targets with the calculated totals.
     */
    updateTotals() {
        console.log("updating totals with dummy values");
        const breakEven = 45;
        const totalFixed = 56;
        const totalPricePer = 78;
        this.totalPricePerTarget.innerText = `${totalPricePer || "-"}`;
        this.totalFixedTarget.innerText = `${totalFixed || "-"}`;
        this.breakEvenTarget.innerText = `${breakEven || "-"}`;
    }
}

const Stimulus = Application.start();

Stimulus.register("budget", BudgetController);
Enter fullscreen mode Exit fullscreen mode
  • Wagtail provides a simple way to load JavaScript into the page editor using the insert_editor_js hook.
  • Create a new file events/wagtail_hooks.py and add the following code.
  • This will load a module script that pulls in our js file.
  • Cross-check Once updated, reload the dev server and then check the event page editing. You should see the dummy values load and the console should log out the message from our controller.
from django.templatetags.static import static
from django.utils.html import format_html

from wagtail import hooks


@hooks.register("insert_editor_js")
def editor_css():
    return format_html(
        '<script type="module" src="{}"></script>',
        static("js/events.js"),
    )

Enter fullscreen mode Exit fullscreen mode

5. Add total calculation logic to JavaScript

  • Update events/static/js/events.js to the following code, we will walk through it after the code snippet.
import {
    Application,
    Controller,
} from "https://unpkg.com/@hotwired/stimulus@3.1.0/dist/stimulus.js";

class BudgetController extends Controller {
    static targets = [
        "breakEven",
        "ticketPrice",
        "totalFixed",
        "totalPricePer",
    ];
    static values = { perPrice: String };

    connect() {
        this.updateTotals();
    }

    /**
     * Parse the inline panel children that are not hidden and read the inner field
     * values, parsing the values into usable JS results.
     */
    get items() {
        const inlinePanelChildren = this.element.querySelectorAll(
            "[data-inline-panel-child]:not(.deleted)"
        );

        return [...inlinePanelChildren].map((element) => ({
            amount: parseFloat(
                element.querySelector("[data-amount]").value || "0"
            ),
            description:
                element.querySelector("[data-description]").value || "",
            type: element.querySelector("[data-type]").value,
        }));
    }

    /**
     * parse ticket price and prepare the totals object to show a summary of
     * totals in the items and the break even quantity required.
     */
    get totals() {
        const perPriceValue = this.perPriceValue;
        const items = this.items;
        const ticketPrice = parseFloat(this.ticketPriceTarget.value || "0");

        const { totalPricePer, totalFixed } = items.reduce(
            (
                { totalPricePer: pp = 0, totalFixed: pf = 0 },
                { amount, type }
            ) => ({
                totalPricePer: type === perPriceValue ? pp + amount : pp,
                totalFixed: type === perPriceValue ? pf : pf + amount,
            }),
            {}
        );

        const totals = {
            breakEven: null,
            ticketPrice,
            totalFixed,
            totalPricePer,
        };

        // do not attempt to show a break even if there is no ticket price
        if (ticketPrice <= 0) return totals;

        const ticketMargin = ticketPrice - totalPricePer;

        // do not attempt to show a break even if ticket price does not cover price per
        if (ticketMargin <= 0) return totals;

        totals.breakEven = Math.ceil(totalFixed / ticketMargin);

        return totals;
    }

    /**
     * Update the DOM targets with the calculated totals.
     */
    updateTotals() {
        const { breakEven, totalFixed, totalPricePer } = this.totals;
        this.totalPricePerTarget.innerText = `${totalPricePer || "-"}`;
        this.totalFixedTarget.innerText = `${totalFixed || "-"}`;
        this.breakEvenTarget.innerText = `${breakEven || "-"}`;
    }
}

const Stimulus = Application.start();

Stimulus.register("budget", BudgetController);
Enter fullscreen mode Exit fullscreen mode
  • static targets - Setting up Targets is a convenient way to provide access to specific elements in the DOM from our controller instance. Remember we put these attributes in the HTML template or our widget attrs, each target can now be accessed from the Controller via this.breakEvenTarget or similar.
  • connect method - This gets called when the Controller is instantiated with a DOM element, it is like constructor in that it runs early and only once.
  • items getter - This is a custom method that pulls in any non-hidden InlinePanel children and then reads the inner values via simple data attributes. Note that we are using this.element here, which will be the budget group panel with the data-controller set on it.
  • totals getter - This does all the bulk calculations, working out the break-even price, the sub-totals and returning an object to be used when updating the DOM. This JavaScript would be essentially the same irrespective of what library is used.
  • updateTotals method - This does the 'work' of updating the DOM, we have kept this method light for readability, it also adds some nicer default values if we get missing/undefined results.
  • Note: The updateTotals method gets triggered as an action because of this line in our HTML data-action="change->budget#updateTotals". This tells Stimulus that whenever any DOM element fires the change event within the group panel container, trigger the updateTotals method. We could enhance this further with data-action="change->budget#updateTotals focusout->budget#updateTotals" which will trigger whenever any focusout event also occurs.
  • Cross-check Now, when you refresh, you should be able to see that your totals are being calculated correctly.

Page editing interface with calculations working

Next steps

There are a few avenues for improvement, at some point though an external application may be more suitable.

  • We may want to provide other totals/calculations or even a profit margin value to the output.
  • We may want to trigger the updateTotals method more frequently based on the user interaction.
  • We could even add a hidden field that determines if the event will make a profit and block submitting if the price is not suitable.
  • Graphs!

For a simple planning tool and especially if the data is already being stored in Wagtail, this is a powerful way to make the editing interface a bit more reactive for users.

Any feedback below in the comments would be appreciated.

Further reading

💖 💪 🙅 🚩
lb
LB (Ben Johnston)

Posted on October 4, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related