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"
toINSTALLED_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 WagtailPage
model and contains things like thePage
title and the ticket price. - Our second model will be a
ParentalKey
(from modelcluster) relation to theEventPage
and be calledEventPageBudgetItem
, eachEventPage
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 thenpython 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",
),
]
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 thetype="number"
by default when you use aDecimal
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
innerFieldPanel
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.
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",
),
]
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 theMultiFieldPanel
, in a new fileevents/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 theoutput
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
- 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 Wagtailmulti_field_panel
template inside our div wrapper. - We are using the
output
element and a suitableh3
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>
- Finally, we need to use this custom panel in our
EventPage
model. - This will be a simple replacement of the
MultiFieldPanel
with ourBudgetGroupPanel
. - Cross-check Once updated, you should now be able to see the
output
content and also check the DOM for the relevant data attributes andfor
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",
),
]
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 fromunpkg
. 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);
- 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"),
)
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);
-
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 widgetattrs
, each target can now be accessed from the Controller viathis.breakEvenTarget
or similar. -
connect
method - This gets called when the Controller is instantiated with a DOM element, it is likeconstructor
in that it runs early and only once. -
items
getter - This is a custom method that pulls in any non-hiddenInlinePanel
children and then reads the inner values via simple data attributes. Note that we are usingthis.element
here, which will be the budget group panel with thedata-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 anaction
because of this line in our HTMLdata-action="change->budget#updateTotals"
. This tells Stimulus that whenever any DOM element fires thechange
event within the group panel container, trigger theupdateTotals
method. We could enhance this further withdata-action="change->budget#updateTotals focusout->budget#updateTotals"
which will trigger whenever anyfocusout
event also occurs. - Cross-check Now, when you refresh, you should be able to see that your totals are being calculated correctly.
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.
- You can read more about the Wagtail 4.0 release.
- Read more about the origin of Stimulus js.
Top comments (0)