Creating an interactive event budgeting tool within Wagtail
Tags: wagtail, javascript, django, stimulus
Published: 2022-10-04 00:49:49

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](https://docs.wagtail.org/en/stable/getting_started/tutorial.html)
-   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`](https://docs.wagtail.org/en/stable/reference/pages/panels.html#inline-panels) 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.

```python
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",
        ),
    ]
```

![Page editing interface with basic fields added](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5ryilf3hj1g6pgc8gwm5.png)


## 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](https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/) and a deep dive on why the [number input is the worst input](https://stackoverflow.blog/2022/09/15/why-the-number-input-is-the-worst-input/).
-   For each of the `InlinePanel` inner `FieldPanel`s 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.

```python
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",
        ),
    ]
```


![Page editing interface with better widgets](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4qtckq6mrpgdc42ck3qd.png)



## 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`](https://docs.wagtail.org/en/stable/reference/pages/panels.html#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`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/output) element.

```python
# 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

```

-   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.

```html
<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>
```

-   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.

```python
# 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",
        ),
    ]

```


![Page editing interface with output container](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vxd1zdt91jvnx4xc1sma.png)



## 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](https://stimulus.hotwired.dev/reference/controllers).

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.

```javascript
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);
```

-   Wagtail provides a simple way to load JavaScript into the page editor using the [`insert_editor_js` hook](https://docs.wagtail.org/en/stable/reference/hooks.html#insert-editor-js).
-   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.

```python
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"),
    )

```

## 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.

```javascript
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);
```

-   `static targets` - Setting up [Targets](https://stimulus.hotwired.dev/reference/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`](https://stimulus.hotwired.dev/reference/actions) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c7cx9xrz64nfsyaywhab.png)



## 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

-   Full code snippets are available at this [gist](https://gist.github.com/lb-/276ac8b60565e106f5e94ca6d608c80c).
-   You can read more about the [Wagtail 4.0 release](https://docs.wagtail.org/en/stable/releases/4.0.html).
-   Read more about the [origin of Stimulus js](https://stimulus.hotwired.dev/handbook/origin).