Hello,
I am building a ledger-like app where users can buy products with their accounts. Users also need to transfer money into their accounts, so a custom transaction is also possible. Because this is infrequent, it is part of the account-update page.
In the spirit of progressive enhancement, the account-update-page (and all other pages for that matter) should also be usable without JavaScript, which means, all interactions must go through forms. On the other hand, if JavaScript is available, some forms should be submitted without page refreshes via fetch()
. The aforementioned account page might look like this:
<h2>User Settings</h2>
<form method="POST">
{{ form }}
<button type="submit">Save</button>
</form>
<h2>Deposit</h2>
<form method="POST">
<input type="hidden" name="action" value="deposit">
{{ deposit_form }}
<button type="submit">Confirm</button>
</form>
The associated view:
class AccountUpdate(UpdateView):
model = Account
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
return super().get_context_data(**kwargs) | {
'deposit_form': TransactionForm(),
}
# url: /accounts/<pk>/
class AccountView(View):
def get(self, request, *args, **kwargs):
view = AccountUpdate.as_view()
return view(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
extra_content = None
if 'action' in request.POST:
form = TransactionForm(self.request.POST)
if form.is_valid():
return custom_transaction(request)
extra_context = {f'deposit_form': form}
view = AccountUpdate.as_view(extra_context=extra_context)
return view(request, *args, **kwargs)
# url: /transaction/custom
def custom_transaction(request: HttpRequest):
try:
if request.content_type == 'application/json':
data = loads(request.body)
else:
data = request.POST
form = TransactionForm(data)
if not form.is_valid():
raise ValidationError('')
# Processing form
if request.accepts('text/html'):
return HttpResponseRedirect(reverse('account_view', args=[account.pk]))
else:
return JsonResponse({'transaction_id': new_transaction.pk})
except ValidationError:
return HttpResponseBadRequest("Invalid form submission: " + form.errors.as_text())
The idea being that if the submitted transaction is valid, you get redirected (to itself); if it is invalid, you get the same page but with form errors.
Now, with JS enabled, the form should instead be submitted via fetch()
with a JSON body (no particular reason other than the ergonomics of using it in JS). Because both modes of operation still need the same processing behind the scenes, I use the “API”-function custom_transaction
in both cases.
Generally I wonder if this is the best approach. Specifically:
- The logic to distinguish between an “API”-Call and a form submission
- The double-validation of the transaction form in the HTML-case (once on
/accounts/<pk>
to see if I need to display the template again, and then on/transaction/custom
to actually process the request) - The logic to distinguish between a “legitimate” account update request and a deposit request
Which brings me to my questions:
- What is the best way to handle multiple actions on a single view?
- How can/should I design views that are used by both regular and
fetch()
-submitted forms?
For the first question, I can think of a few alternatives:
- Use a separate url for every action (e.g.
/accounts/<pk>
for update,/accounts/<pk>/deposit
for deposit transactions, etc.) - Use distinguishing hidden fields like in the example and detect them in the
post()
-handler, delegate accordingly - Use distinguishing submit buttons (e.g.
<button type="submit" name="deposit">
) and detect their presence inpost()
- Use form prefixes and detect them somehow
My main problem with these approaches is
- For 1, that the form errors are not redirected to the page they originated from
(think of a user submitting an invalid deposit, landing on the same page under a different domain and from there submitting an account update) - For 2, 3, 4 the required nontivial logic to distinguish forms and insert them, if invalid, back into the template, but branch to an API-route if valid.
Both main strategies feel like unintended (one might say bad) design.
I’d appreciate some guidance.