Skip to content

Instantly share code, notes, and snippets.

@lb-
Created October 4, 2022 00:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lb-/276ac8b60565e106f5e94ca6d608c80c to your computer and use it in GitHub Desktop.
Save lb-/276ac8b60565e106f5e94ca6d608c80c to your computer and use it in GitHub Desktop.
Creating an interactive event budgeting tool within Wagtail (Tutorial)
<div
data-controller="budget"
data-action="change->budget#updateTotals focusout->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>
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"];
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 = "PP";
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);
from django import forms
from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.admin.panels import FieldPanel, FieldRowPanel, InlinePanel
from wagtail.models import Orderable, Page
from .panels import BudgetGroupPanel
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",
widget=forms.TextInput(attrs={"data-description": ""}),
),
FieldPanel("price_type", widget=forms.Select(attrs={"data-type": ""})),
FieldPanel(
"amount",
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 + [
BudgetGroupPanel(
[
InlinePanel("related_budget_items"),
FieldPanel(
"ticket_price",
widget=forms.TextInput(
attrs={
"data-budget-target": "ticketPrice",
**NUMBER_FIELD_ATTRS,
}
),
),
],
"Budget",
),
]
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
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"),
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment