Skip to content

Commit a2f0364

Browse files
committed
feat: release 0.54.0 — success pages, answer piping, notification subject piping, 29 new tests, docs
1 parent 5897934 commit a2f0364

18 files changed

Lines changed: 980 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.54.0] - 2026-04-01
11+
12+
### Added
13+
- **Success Pages** — Three new fields on `FormDefinition` control post-submission routing, evaluated in priority order:
14+
- `success_redirect_rules` (JSONField) — array of conditional redirect rules; first match wins. Each rule combines a `url` with any condition the existing evaluate-conditions engine understands (simple field equality, compound AND/OR, etc.).
15+
- `success_redirect_url` (CharField) — static redirect URL, applied when no rule matches.
16+
- `success_message` (TextField) — custom HTML rendered at `/submissions/<id>/success/` when no redirect is configured.
17+
- New `submission_success` URL (`forms_workflows:submission_success`) and view; template `submission_success.html` with "My Submissions" / "Back to Forms" navigation.
18+
- **Answer piping**`{field_name}` token substitution available in three places:
19+
- *Success messages & redirect URLs*`_pipe_answer_tokens()` helper resolves tokens server-side from `form_data`; list-valued fields are comma-joined; unknown tokens become empty strings.
20+
- *Notification subjects*`subject_template` on `NotificationRule` now resolves `{field_name}` tokens alongside `{form_name}` and `{submission_id}` via a `defaultdict(str)` (fail-open: unknown tokens → empty string).
21+
- *Live form labels* — new JS block in `form_submit.html` scans `<label>`, `<small>`, and `.form-text` elements for tokens, attaches `input`/`change` listeners, and replaces them in real-time as the user types.
22+
- **`form_data` in notification email context** — the full `form_data` dict is now passed to every notification template so HTML templates can reference `{{ form_data.field_name }}` directly.
23+
- **Form builder: Success Page settings panel** — redirect URL input, conditional rules JSON editor, success message textarea with piping syntax hints, and a CAPTCHA toggle; all values round-trip through `form_builder_load` / `form_builder_save`.
24+
- **Migration `0081`** (`0081_add_success_page_fields`) — adds `success_message`, `success_redirect_url`, and `success_redirect_rules` to `FormDefinition`.
25+
26+
### Documentation
27+
- `docs/POST_SUBMISSION_ACTIONS.md` — new **Success Pages** and **Answer Piping** sections.
28+
- `docs/NOTIFICATIONS.md` — new **Answer Piping in Subjects** section; `form_data` template context documented.
29+
- `docs/FORM_BUILDER_USER_GUIDE.md` — Submission Controls panel, Success Page settings panel, palette search, and four new field types documented.
30+
- `docs/CLIENT_SIDE_ENHANCEMENTS.md` — new **Real-Time Answer Piping** section (§ 6).
31+
32+
### Tests
33+
- New tests in `tests/test_views.py`: `TestPipeAnswerTokens` (9 cases), `TestSubmissionSuccessView` (5 cases), `TestSuccessRouting` (10 cases).
34+
- New tests in `tests/test_notifications.py`: 5 cases covering subject piping, unknown-field fail-open, built-in placeholders, list-value comma-join, and `form_data` in template context.
35+
1036
## [0.53.0] - 2026-04-01
1137

1238
### Added

django_forms_workflows/form_builder_views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ def form_builder_load(request, form_id):
311311
"requires_login": form_definition.requires_login,
312312
"allow_save_draft": form_definition.allow_save_draft,
313313
"allow_withdrawal": form_definition.allow_withdrawal,
314+
"success_message": form_definition.success_message or "",
315+
"success_redirect_url": form_definition.success_redirect_url or "",
316+
"success_redirect_rules": form_definition.success_redirect_rules,
314317
"close_date": form_definition.close_date.isoformat()
315318
if form_definition.close_date
316319
else None,
@@ -349,6 +352,9 @@ def form_builder_save(request):
349352
requires_login = data.get("requires_login", True)
350353
allow_save_draft = data.get("allow_save_draft", True)
351354
allow_withdrawal = data.get("allow_withdrawal", True)
355+
success_message = data.get("success_message", "").strip()
356+
success_redirect_url = data.get("success_redirect_url", "").strip()
357+
success_redirect_rules = data.get("success_redirect_rules") or None
352358
close_date = data.get("close_date") or None
353359
max_submissions = data.get("max_submissions") or None
354360
one_per_user = data.get("one_per_user", False)
@@ -382,6 +388,9 @@ def form_builder_save(request):
382388
form_definition.requires_login = requires_login
383389
form_definition.allow_save_draft = allow_save_draft
384390
form_definition.allow_withdrawal = allow_withdrawal
391+
form_definition.success_message = success_message
392+
form_definition.success_redirect_url = success_redirect_url
393+
form_definition.success_redirect_rules = success_redirect_rules
385394
form_definition.close_date = close_date
386395
form_definition.max_submissions = max_submissions
387396
form_definition.one_per_user = one_per_user
@@ -402,6 +411,9 @@ def form_builder_save(request):
402411
requires_login=requires_login,
403412
allow_save_draft=allow_save_draft,
404413
allow_withdrawal=allow_withdrawal,
414+
success_message=success_message,
415+
success_redirect_url=success_redirect_url,
416+
success_redirect_rules=success_redirect_rules,
405417
close_date=close_date,
406418
max_submissions=max_submissions,
407419
one_per_user=one_per_user,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Generated by Django 5.2.7 on 2026-04-01 10:30
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
(
10+
"django_forms_workflows",
11+
"0080_add_rating_matrix_address_slider_fields_and_form_controls",
12+
),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="formdefinition",
18+
name="success_message",
19+
field=models.TextField(
20+
blank=True,
21+
help_text="Custom HTML shown after submission. Supports answer piping with {field_name} tokens that are replaced with the submitted values. Leave blank for the default confirmation message.",
22+
),
23+
),
24+
migrations.AddField(
25+
model_name="formdefinition",
26+
name="success_redirect_rules",
27+
field=models.JSONField(
28+
blank=True,
29+
help_text='Conditional redirects based on form data. Format: [{"field": "field_name", "operator": "equals", "value": "...", "url": "https://..."}]. First matching rule wins; falls back to success_redirect_url or the default page.',
30+
null=True,
31+
),
32+
),
33+
migrations.AddField(
34+
model_name="formdefinition",
35+
name="success_redirect_url",
36+
field=models.URLField(
37+
blank=True,
38+
help_text="Redirect to this URL after submission instead of showing a success page.",
39+
),
40+
),
41+
]

django_forms_workflows/models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,29 @@ class FormDefinition(models.Model):
252252
help_text="Auto-save interval in seconds",
253253
)
254254

255+
# Success Page
256+
success_message = models.TextField(
257+
blank=True,
258+
help_text=(
259+
"Custom HTML shown after submission. Supports answer piping with "
260+
"{field_name} tokens that are replaced with the submitted values. "
261+
"Leave blank for the default confirmation message."
262+
),
263+
)
264+
success_redirect_url = models.URLField(
265+
blank=True,
266+
help_text="Redirect to this URL after submission instead of showing a success page.",
267+
)
268+
success_redirect_rules = models.JSONField(
269+
blank=True,
270+
null=True,
271+
help_text=(
272+
"Conditional redirects based on form data. Format: "
273+
'[{"field": "field_name", "operator": "equals", "value": "...", "url": "https://..."}]. '
274+
"First matching rule wins; falls back to success_redirect_url or the default page."
275+
),
276+
)
277+
255278
# PDF Generation
256279
PDF_GENERATION_CHOICES = [
257280
("none", "Disabled"),

django_forms_workflows/static/django_forms_workflows/js/form-builder.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,6 +1955,11 @@ class FormBuilder {
19551955
document.getElementById('formAllowDraft').checked = data.allow_save_draft;
19561956
document.getElementById('formAllowWithdrawal').checked = data.allow_withdrawal;
19571957

1958+
// Load success page settings
1959+
document.getElementById('formSuccessMessage').value = data.success_message || '';
1960+
document.getElementById('formSuccessRedirectUrl').value = data.success_redirect_url || '';
1961+
document.getElementById('formSuccessRedirectRules').value = data.success_redirect_rules ? JSON.stringify(data.success_redirect_rules) : '';
1962+
19581963
// Load submission controls
19591964
if (data.close_date) document.getElementById('formCloseDate').value = data.close_date.slice(0, 16);
19601965
if (data.max_submissions) document.getElementById('formMaxSubmissions').value = data.max_submissions;
@@ -2018,6 +2023,9 @@ class FormBuilder {
20182023
requires_login: document.getElementById('formRequiresLogin').checked,
20192024
allow_save_draft: document.getElementById('formAllowDraft').checked,
20202025
allow_withdrawal: document.getElementById('formAllowWithdrawal').checked,
2026+
success_message: document.getElementById('formSuccessMessage').value.trim(),
2027+
success_redirect_url: document.getElementById('formSuccessRedirectUrl').value.trim(),
2028+
success_redirect_rules: (() => { try { const v = document.getElementById('formSuccessRedirectRules').value.trim(); return v ? JSON.parse(v) : null; } catch(e) { return null; } })(),
20212029
close_date: document.getElementById('formCloseDate').value || null,
20222030
max_submissions: parseInt(document.getElementById('formMaxSubmissions').value) || null,
20232031
one_per_user: document.getElementById('formOnePerUser').checked,

django_forms_workflows/tasks.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,17 +1200,28 @@ def send_notification_rules(
12001200
)
12011201
continue
12021202

1203-
subject = (
1204-
rule.subject_template.format(
1205-
form_name=form_name, submission_id=submission.id
1206-
)
1207-
if rule.subject_template
1208-
else default_subject_tpl.format(
1203+
# Build subject with answer piping — {field_name} tokens are
1204+
# resolved from form_data alongside the built-in {form_name} and
1205+
# {submission_id} placeholders.
1206+
_pipe_vars = {
1207+
"form_name": form_name,
1208+
"submission_id": submission.id,
1209+
**{
1210+
k: (", ".join(v) if isinstance(v, list) else str(v))
1211+
for k, v in form_data.items()
1212+
},
1213+
}
1214+
subject_tpl = rule.subject_template or default_subject_tpl
1215+
try:
1216+
subject = subject_tpl.format_map(defaultdict(str, _pipe_vars))
1217+
except Exception:
1218+
subject = subject_tpl.format(
12091219
form_name=form_name, submission_id=submission.id
12101220
)
1211-
)
1221+
12121222
context = {
12131223
"submission": submission,
1224+
"form_data": form_data,
12141225
"submission_url": submission_url,
12151226
"approval_url": approval_url,
12161227
"hide_approval_history": hide_approval_history,

django_forms_workflows/templates/admin/django_forms_workflows/form_builder.html

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,27 @@ <h5 class="mb-0"><i class="bi bi-gear"></i> Form Settings</h5>
711711
</div>
712712
</div>
713713

714+
<!-- Success Page -->
715+
<div class="col-12 mt-3">
716+
<h6 class="text-muted border-bottom pb-2">
717+
<i class="bi bi-check-circle"></i> Success Page
718+
</h6>
719+
</div>
720+
<div class="col-md-6">
721+
<label class="form-label small">Redirect URL <span class="text-muted">(optional)</span></label>
722+
<input type="url" class="form-control form-control-sm" id="formSuccessRedirectUrl" placeholder="https://example.com/thank-you">
723+
<small class="text-muted">Redirect here after submission. Supports <code>{field_name}</code> tokens.</small>
724+
</div>
725+
<div class="col-md-6">
726+
<label class="form-label small">Conditional Redirects <span class="text-muted">(JSON, optional)</span></label>
727+
<input type="text" class="form-control form-control-sm font-monospace" id="formSuccessRedirectRules" placeholder='[{"field":"...", "operator":"equals", "value":"...", "url":"..."}]'>
728+
</div>
729+
<div class="col-12">
730+
<label class="form-label small">Custom Success Message <span class="text-muted">(HTML, optional)</span></label>
731+
<textarea class="form-control form-control-sm" id="formSuccessMessage" rows="3" placeholder="Thank you, {first_name}! Your {form_type} request has been submitted."></textarea>
732+
<small class="text-muted">Shown after submission. Use <code>{field_name}</code> for answer piping. Leave blank for default.</small>
733+
</div>
734+
714735
<!-- Submission Controls -->
715736
<div class="col-12 mt-3">
716737
<h6 class="text-muted border-bottom pb-2">

django_forms_workflows/templates/django_forms_workflows/form_submit.html

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,60 @@ <h1>{{ form_def.name }}</h1>
203203
document.querySelectorAll('form').forEach(wireCalculatedFields);
204204
})();
205205

206+
// ── Answer piping: replace {field_name} tokens in labels & help text ─
207+
(function () {
208+
var form = document.querySelector('[data-form-enhancements="true"]');
209+
if (!form) return;
210+
211+
// Find all labels and help-text spans that contain {tokens}
212+
var TOKEN_RE = /\{(\w+)\}/g;
213+
var pipeTargets = [];
214+
215+
form.querySelectorAll('label, .form-text, .help-block, small').forEach(function (el) {
216+
var tpl = el.textContent;
217+
if (TOKEN_RE.test(tpl)) {
218+
pipeTargets.push({ el: el, template: tpl });
219+
}
220+
TOKEN_RE.lastIndex = 0; // reset regex state
221+
});
222+
223+
if (!pipeTargets.length) return;
224+
225+
// Collect all referenced field names
226+
var sourceNames = new Set();
227+
pipeTargets.forEach(function (t) {
228+
var m;
229+
TOKEN_RE.lastIndex = 0;
230+
while ((m = TOKEN_RE.exec(t.template)) !== null) {
231+
sourceNames.add(m[1]);
232+
}
233+
});
234+
235+
function updatePipes() {
236+
pipeTargets.forEach(function (t) {
237+
t.el.textContent = t.template.replace(TOKEN_RE, function (_, name) {
238+
var el = form.querySelector('[name="' + name + '"]');
239+
if (!el) return '{' + name + '}';
240+
if (el.type === 'checkbox') return el.checked ? (el.value || 'Yes') : '';
241+
if (el.tagName === 'SELECT') {
242+
var opt = el.options[el.selectedIndex];
243+
return opt ? opt.text : '';
244+
}
245+
return el.value || '';
246+
});
247+
});
248+
}
249+
250+
sourceNames.forEach(function (name) {
251+
var el = form.querySelector('[name="' + name + '"]');
252+
if (el) {
253+
el.addEventListener('input', updatePipes);
254+
el.addEventListener('change', updatePipes);
255+
}
256+
});
257+
updatePipes();
258+
})();
259+
206260
// ── Rating stars widget ──────────────────────────────────────────────
207261
(function () {
208262
document.querySelectorAll('.rating-stars-input').forEach(function (select) {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{% extends "django_forms_workflows/base.html" %}
2+
3+
{% block title %}Submission Received — {{ form_def.name }}{% endblock %}
4+
5+
{% block content %}
6+
<div class="container py-5">
7+
<div class="row justify-content-center">
8+
<div class="col-md-8 col-lg-6">
9+
<div class="card shadow-sm p-4">
10+
<div class="text-center mb-4">
11+
<i class="bi bi-check-circle-fill text-success" style="font-size: 3.5rem;"></i>
12+
</div>
13+
<div class="success-message">
14+
{{ success_message|safe }}
15+
</div>
16+
<div class="d-grid gap-2 mt-4">
17+
{% if request.user.is_authenticated %}
18+
<a href="{% url 'forms_workflows:my_submissions' %}" class="btn btn-primary">
19+
<i class="bi bi-list-check"></i> My Submissions
20+
</a>
21+
{% endif %}
22+
<a href="{% url 'forms_workflows:form_list' %}" class="btn btn-outline-secondary">
23+
<i class="bi bi-arrow-left"></i> Back to Forms
24+
</a>
25+
</div>
26+
</div>
27+
</div>
28+
</div>
29+
</div>
30+
{% endblock %}

django_forms_workflows/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@
8484
views.reassign_task,
8585
name="reassign_task",
8686
),
87+
# Custom success page (per-submission, supports answer piping)
88+
path(
89+
"submissions/<int:submission_id>/success/",
90+
views.submission_success,
91+
name="submission_success",
92+
),
8793
# Public form submission confirmation (no login required)
8894
path(
8995
"submitted/",

0 commit comments

Comments
 (0)