Skip to content

Commit c89d7f8

Browse files
committed
feat: hide comment field, phone pattern validation, file persistence, alignment fix
- Add hide_comment_field BooleanField to WorkflowStage to control comment visibility on approval forms per stage. - Add HTML5 pattern attribute to phone fields for immediate browser validation; country code prefixes (+1, +44) accepted. - Save uploaded files eagerly before validation so they survive validation failures. Persist files with drafts. Carry forward existing file metadata on resubmit when no new file is uploaded. - Group consecutive third/fourth-width fields in ApprovalStepForm into a single Row with align-items-start for consistent alignment. Bump version to 0.63.0.
1 parent bb3562d commit c89d7f8

File tree

8 files changed

+268
-29
lines changed

8 files changed

+268
-29
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.63.0] - 2026-04-02
11+
12+
### Added
13+
- **`hide_comment_field` on WorkflowStage** — new boolean field allows hiding
14+
the public "Decision Comment" textarea from approvers on a per-stage basis.
15+
Useful for stages with no submitter notifications or where the comment is
16+
not meaningful.
17+
- **Phone field HTML5 pattern validation** — phone fields now include a
18+
`pattern` attribute so browsers provide immediate visual feedback (like email
19+
fields do). Rejects non-phone text such as "test" at input time. Country
20+
code prefixes (`+1`, `+44`, etc.) are accepted.
21+
22+
### Fixed
23+
- **File uploads preserved on validation failure** — uploaded files are now
24+
saved to storage eagerly before form validation. On a validation-failure
25+
re-render the form shows "Previously uploaded: filename" and the field
26+
becomes optional, preventing file loss.
27+
- **File uploads saved with drafts** — clicking "Save Draft" now persists
28+
any uploaded files alongside the draft form data.
29+
- **File carry-forward on resubmit** — when no new file is uploaded,
30+
previously-stored file metadata is carried forward instead of being
31+
discarded.
32+
- **Field vertical alignment in approval step forms** — consecutive
33+
third/fourth-width fields are now grouped into a single row with
34+
`align-items-start`, matching the main form's behaviour and preventing
35+
misaligned labels/inputs.
36+
1037
## [0.62.1] - 2026-04-02
1138

1239
### Fixed

django_forms_workflows/admin.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ class WorkflowStageInline(nested_admin.NestedStackedInline):
496496
("assignee_form_field", "assignee_lookup_type"),
497497
"validate_assignee_group",
498498
("allow_reassign", "allow_send_back"),
499-
"allow_edit_form_data",
499+
("allow_edit_form_data", "hide_comment_field"),
500500
"trigger_conditions",
501501
)
502502

@@ -958,6 +958,8 @@ def clone_forms(self, request, queryset):
958958
validate_assignee_group=stage.validate_assignee_group,
959959
allow_reassign=stage.allow_reassign,
960960
allow_send_back=stage.allow_send_back,
961+
allow_edit_form_data=stage.allow_edit_form_data,
962+
hide_comment_field=stage.hide_comment_field,
961963
)
962964
for sag in StageApprovalGroup.objects.filter(
963965
stage=stage
@@ -1591,14 +1593,11 @@ class WorkflowStageAdmin(nested_admin.NestedModelAdmin):
15911593
},
15921594
),
15931595
(
1594-
"Editable Form Data",
1596+
"Approver UI Options",
15951597
{
15961598
"classes": ("collapse",),
1597-
"description": (
1598-
"When enabled, approvers at this stage can edit the original "
1599-
"submission data instead of viewing it read-only."
1600-
),
1601-
"fields": ("allow_edit_form_data",),
1599+
"description": ("Control what approvers see and can do at this stage."),
1600+
"fields": ("allow_edit_form_data", "hide_comment_field"),
16021601
},
16031602
),
16041603
(

django_forms_workflows/forms.py

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,23 @@ class DynamicForm(forms.Form):
223223
- Draft saving
224224
"""
225225

226-
def __init__(self, form_definition, user=None, initial_data=None, *args, **kwargs):
226+
def __init__(
227+
self,
228+
form_definition,
229+
user=None,
230+
initial_data=None,
231+
stashed_files=None,
232+
*args,
233+
**kwargs,
234+
):
227235
super().__init__(*args, **kwargs)
228236
self.form_definition = form_definition
229237
self.user = user
230238
self.initial_data = initial_data or {}
239+
# File metadata saved eagerly before validation so file uploads
240+
# survive a validation-failure re-render (browsers don't re-send
241+
# files). Also used to carry forward draft / resubmit file data.
242+
self.stashed_files = stashed_files or {}
231243

232244
# Build form fields from definition
233245
# Exclude fields scoped to a workflow stage - those are for approvers only
@@ -430,6 +442,25 @@ def _get_choices_from_prefill_source(self, field_def):
430442
source = DatabaseDataSource()
431443
return source.execute_choices_query(query_key)
432444

445+
def _get_existing_file_info(self, field_name):
446+
"""Return previously-uploaded file metadata for *field_name*, if any.
447+
448+
Checks ``stashed_files`` (validation-failure recovery) first, then
449+
falls back to ``initial_data`` (draft / resubmit). Returns ``None``
450+
if no file info is found.
451+
"""
452+
info = self.stashed_files.get(field_name)
453+
if info and isinstance(info, dict) and info.get("filename"):
454+
return info
455+
if info and isinstance(info, list):
456+
return info
457+
info = (self.initial_data or {}).get(field_name)
458+
if info and isinstance(info, dict) and info.get("filename"):
459+
return info
460+
if info and isinstance(info, list):
461+
return info
462+
return None
463+
433464
def add_field(self, field_def, initial_data):
434465
"""Add a field to the form based on field definition"""
435466

@@ -471,8 +502,14 @@ def add_field(self, field_def, initial_data):
471502
{
472503
"type": "tel",
473504
"inputmode": "tel",
505+
"pattern": r"\+?[\d()\- .]{7,25}",
506+
"title": (
507+
"Enter a valid phone number, e.g. 2065551234, "
508+
"(206) 555-1234, or +1 (206) 555-1234"
509+
),
474510
"placeholder": widget_attrs.get(
475-
"placeholder", "e.g. 2065551234 or (206) 555-1234"
511+
"placeholder",
512+
"e.g. 2065551234, (206) 555-1234, or +1 2065551234",
476513
),
477514
}
478515
)
@@ -606,6 +643,19 @@ def add_field(self, field_def, initial_data):
606643
if e.strip()
607644
)
608645
accept_attrs["accept"] = exts
646+
647+
# Check for a previously-uploaded file (stashed on validation
648+
# failure, saved with a draft, or carried from a resubmission).
649+
existing_file = self._get_existing_file_info(field_def.field_name)
650+
if existing_file:
651+
fname = existing_file.get("filename", "file")
652+
extra_help = f"Previously uploaded: <strong>{fname}</strong>. Leave blank to keep it."
653+
base_help = field_args.get("help_text", "") or ""
654+
field_args["help_text"] = (
655+
f"{base_help} {extra_help}" if base_help else extra_help
656+
)
657+
field_args["required"] = False
658+
609659
self.fields[field_def.field_name] = forms.FileField(
610660
validators=file_validators,
611661
widget=forms.ClearableFileInput(attrs=accept_attrs)
@@ -615,10 +665,22 @@ def add_field(self, field_def, initial_data):
615665
)
616666

617667
elif field_def.field_type == "multifile":
668+
existing_file = self._get_existing_file_info(field_def.field_name)
669+
required = field_def.required
670+
help_text = field_def.help_text
671+
if existing_file:
672+
names = (
673+
", ".join(f.get("filename", "file") for f in existing_file)
674+
if isinstance(existing_file, list)
675+
else existing_file.get("filename", "file")
676+
)
677+
extra = f"Previously uploaded: <strong>{names}</strong>. Leave blank to keep."
678+
help_text = f"{help_text} {extra}" if help_text else extra
679+
required = False
618680
self.fields[field_def.field_name] = MultipleFileField(
619-
required=field_def.required,
681+
required=required,
620682
label=field_def.field_label,
621-
help_text=field_def.help_text,
683+
help_text=help_text,
622684
)
623685

624686
elif field_def.field_type == "calculated":
@@ -1441,8 +1503,14 @@ def _create_field(self, field_def, field_args, widget_attrs, is_editable):
14411503
{
14421504
"type": "tel",
14431505
"inputmode": "tel",
1506+
"pattern": r"\+?[\d()\- .]{7,25}",
1507+
"title": (
1508+
"Enter a valid phone number, e.g. 2065551234, "
1509+
"(206) 555-1234, or +1 (206) 555-1234"
1510+
),
14441511
"placeholder": widget_attrs.get(
1445-
"placeholder", "e.g. 2065551234 or (206) 555-1234"
1512+
"placeholder",
1513+
"e.g. 2065551234, (206) 555-1234, or +1 2065551234",
14461514
),
14471515
}
14481516
)
@@ -1716,6 +1784,7 @@ def _build_layout_fields(self, field_defs):
17161784
Field(next_field.field_name),
17171785
css_class=f"col-md-6 field-wrapper field-{next_field.field_name}",
17181786
),
1787+
css_class="align-items-start",
17191788
)
17201789
)
17211790
i += 2
@@ -1727,22 +1796,31 @@ def _build_layout_fields(self, field_defs):
17271796
)
17281797
)
17291798
i += 1
1730-
elif field.width == "third":
1731-
layout_fields.append(
1732-
Div(
1733-
Row(Column(Field(field.field_name), css_class="col-md-4")),
1734-
css_class=f"field-wrapper field-{field.field_name}",
1735-
)
1736-
)
1737-
i += 1
1738-
elif field.width == "fourth":
1799+
elif field.width in ("third", "fourth"):
1800+
# Collect consecutive same-width fields into a single Row.
1801+
col_class = "col-md-4" if field.width == "third" else "col-md-3"
1802+
max_per_row = 3 if field.width == "third" else 4
1803+
group = []
1804+
while (
1805+
i < len(fields)
1806+
and len(group) < max_per_row
1807+
and fields[i].width == field.width
1808+
and fields[i].field_type != "section"
1809+
):
1810+
group.append(fields[i])
1811+
i += 1
17391812
layout_fields.append(
1740-
Div(
1741-
Row(Column(Field(field.field_name), css_class="col-md-3")),
1742-
css_class=f"field-wrapper field-{field.field_name}",
1813+
Row(
1814+
*[
1815+
Div(
1816+
Field(f.field_name),
1817+
css_class=f"{col_class} field-wrapper field-{f.field_name}",
1818+
)
1819+
for f in group
1820+
],
1821+
css_class="align-items-start",
17431822
)
17441823
)
1745-
i += 1
17461824
else:
17471825
layout_fields.append(
17481826
Div(
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 6.0.3 on 2026-04-02 18:45
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("django_forms_workflows", "0088_notificationrule_body_template_and_events"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="workflowstage",
15+
name="hide_comment_field",
16+
field=models.BooleanField(
17+
default=False,
18+
help_text="Hide the public decision comment field from approvers at this stage. Useful for stages where no notifications are sent to the submitter or where the comment would not be meaningful.",
19+
),
20+
),
21+
]

django_forms_workflows/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,14 @@ class WorkflowStage(models.Model):
12261226
"approver approves the submission."
12271227
),
12281228
)
1229+
hide_comment_field = models.BooleanField(
1230+
default=False,
1231+
help_text=(
1232+
"Hide the public decision comment field from approvers at this stage. "
1233+
"Useful for stages where no notifications are sent to the submitter "
1234+
"or where the comment would not be meaningful."
1235+
),
1236+
)
12291237

12301238
change_history = GenericRelation(
12311239
"django_forms_workflows.ChangeHistory",

django_forms_workflows/templates/django_forms_workflows/approve.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ <h5 class="mb-0"><i class="bi bi-pencil-square"></i> {% if current_step_number %
345345

346346
<!-- Decision Section inline -->
347347
<h6 class="mb-3"><i class="bi bi-clipboard-check"></i> Your Decision</h6>
348+
{% if not hide_comment_field %}
348349
<div class="mb-3">
349350
<label for="comments" class="form-label">
350351
Decision Comment
@@ -358,6 +359,7 @@ <h6 class="mb-3"><i class="bi bi-clipboard-check"></i> Your Decision</h6>
358359
This comment is <strong>visible to the submitter</strong> and appears on the public submission record.
359360
</div>
360361
</div>
362+
{% endif %}
361363
<div class="d-grid gap-2 d-md-block">
362364
<button type="submit" name="decision" value="approve" class="btn btn-success btn-lg">
363365
<i class="bi bi-check-circle"></i> {{ approve_label|default:"Approve" }}
@@ -596,6 +598,7 @@ <h5 class="mb-0"><i class="bi bi-clipboard-check"></i> Your Decision</h5>
596598
<div class="card-body">
597599
<form method="post" data-loading-message="Processing your decision...">
598600
{% csrf_token %}
601+
{% if not hide_comment_field %}
599602
<div class="mb-3">
600603
<label for="comments" class="form-label">
601604
Decision Comment
@@ -609,6 +612,7 @@ <h5 class="mb-0"><i class="bi bi-clipboard-check"></i> Your Decision</h5>
609612
This comment is <strong>visible to the submitter</strong> and appears on the public submission record.
610613
</div>
611614
</div>
615+
{% endif %}
612616
<div class="d-grid gap-2 d-md-block">
613617
<button type="submit" name="decision" value="approve" class="btn btn-success btn-lg">
614618
<i class="bi bi-check-circle"></i> {{ approve_label|default:"Approve" }}

0 commit comments

Comments
 (0)