wtforms-htmx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wtforms_htmx/__init__.py +18 -0
- wtforms_htmx/form.py +761 -0
- wtforms_htmx/headers.py +55 -0
- wtforms_htmx/meta.py +416 -0
- wtforms_htmx/mutations.py +311 -0
- wtforms_htmx/partial.py +308 -0
- wtforms_htmx/py.typed +0 -0
- wtforms_htmx/tree.py +147 -0
- wtforms_htmx/triggers.py +182 -0
- wtforms_htmx/wrapper.py +24 -0
- wtforms_htmx-0.1.0.dist-info/METADATA +87 -0
- wtforms_htmx-0.1.0.dist-info/RECORD +14 -0
- wtforms_htmx-0.1.0.dist-info/WHEEL +4 -0
- wtforms_htmx-0.1.0.dist-info/licenses/LICENSE.rst +28 -0
wtforms_htmx/form.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
"""Form mixin: request dispatch and partial rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from markupsafe import Markup
|
|
10
|
+
from wtforms import Form
|
|
11
|
+
from wtforms.fields import FieldList
|
|
12
|
+
from wtforms.fields import FormField
|
|
13
|
+
from wtforms.fields.choices import SelectFieldBase
|
|
14
|
+
from wtforms.widgets import html_params
|
|
15
|
+
from wtforms.widgets import ListWidget
|
|
16
|
+
from wtforms.widgets import Select
|
|
17
|
+
|
|
18
|
+
from .headers import _parse_request
|
|
19
|
+
from .meta import _is_source_posted
|
|
20
|
+
from .meta import HtmxMeta
|
|
21
|
+
from .partial import _compute_partial
|
|
22
|
+
from .partial import _HtmxMutation
|
|
23
|
+
from .tree import _classify_target
|
|
24
|
+
from .tree import _resolve_field
|
|
25
|
+
from .tree import _resolve_field_by_id
|
|
26
|
+
from .tree import _root_of
|
|
27
|
+
from .tree import _state
|
|
28
|
+
from .tree import _walk_fields
|
|
29
|
+
from .triggers import _TriggerEntry
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from wtforms.fields import Field
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_submit_source(field: Any) -> bool:
|
|
36
|
+
"""Whether ``field`` submits the form on click.
|
|
37
|
+
|
|
38
|
+
True when its widget, or the option widget of a choices field, is in
|
|
39
|
+
``meta.htmx_submit_widgets``.
|
|
40
|
+
"""
|
|
41
|
+
submit_widgets = getattr(field.meta, "htmx_submit_widgets", ())
|
|
42
|
+
if not submit_widgets:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
if isinstance(field, SelectFieldBase):
|
|
46
|
+
return isinstance(field.option_widget, submit_widgets)
|
|
47
|
+
|
|
48
|
+
return isinstance(field.widget, submit_widgets)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _iter_submit_sources(form: Any) -> Any:
|
|
52
|
+
"""Yield the submit-source fields of the whole form tree, nested ones included."""
|
|
53
|
+
return (field for field in _walk_fields(form) if _is_submit_source(field))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_field_by_datalist_id(container: Any, datalist_id: str):
|
|
57
|
+
"""Find the field whose bound DataList has the id ``datalist_id``.
|
|
58
|
+
|
|
59
|
+
Match the DataList's actual id rather than a ``-datalist`` suffix.
|
|
60
|
+
"""
|
|
61
|
+
for field in _walk_fields(container):
|
|
62
|
+
datalist = getattr(field, "_datalist", None)
|
|
63
|
+
if (
|
|
64
|
+
datalist is not None
|
|
65
|
+
and not isinstance(datalist, str)
|
|
66
|
+
and datalist.id == datalist_id
|
|
67
|
+
):
|
|
68
|
+
return field
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve_source_field(container: Any, element_id: str | None) -> Field | None:
|
|
74
|
+
"""Find the field that owns the element whose id is ``element_id``.
|
|
75
|
+
|
|
76
|
+
Exact ids first, which is what a FieldList entry matches. Then a single
|
|
77
|
+
trailing ``-<n>`` is trimmed, so a choice option (``color-0``) routes to
|
|
78
|
+
its field. The trim never repeats.
|
|
79
|
+
"""
|
|
80
|
+
if not element_id:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
if (field := _resolve_field_by_id(container, element_id)) is not None:
|
|
84
|
+
return field
|
|
85
|
+
|
|
86
|
+
prefix, separator, tail = element_id.rpartition("-")
|
|
87
|
+
if not separator or not tail.isdigit():
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
return _resolve_field_by_id(container, prefix)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _add_trigger_source(
|
|
94
|
+
target: Any, source_id: str, source_name: str, entry: Any
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Append ``(source_id, source_name, entry)`` to ``target._htmx_trigger_sources``."""
|
|
97
|
+
incoming = _state(target, "_htmx_trigger_sources")
|
|
98
|
+
if incoming is None:
|
|
99
|
+
incoming = []
|
|
100
|
+
target._htmx_trigger_sources = incoming
|
|
101
|
+
|
|
102
|
+
incoming.append((source_id, source_name, entry))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _inline_validate(form: Any, _source: Any, _target: Any) -> None:
|
|
106
|
+
"""Inline-validation callback: validate the whole form.
|
|
107
|
+
|
|
108
|
+
It leaves ``target.data`` untouched, so the field keeps what the user
|
|
109
|
+
typed.
|
|
110
|
+
"""
|
|
111
|
+
form.validate()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _inline_validation_fields(fields: Any) -> Any:
|
|
115
|
+
"""Yield the plain fields of ``fields``, and the rows of their FieldLists."""
|
|
116
|
+
for field in fields:
|
|
117
|
+
if isinstance(field, FieldList):
|
|
118
|
+
yield from _inline_validation_fields(field.entries)
|
|
119
|
+
elif not isinstance(field, FormField):
|
|
120
|
+
yield field
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_HTMX_TARGET_KEYWORDS = frozenset({"this", "next", "previous", "body"})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _button_attrs(form: Any, holder: Any, target_name: str) -> dict[str, Any]:
|
|
127
|
+
"""Return the htmx attributes of a button that fires ``holder``'s mutation."""
|
|
128
|
+
host = getattr(holder, "_form", None) or form
|
|
129
|
+
kind, target = _classify_target(host, _root_of(form), target_name)
|
|
130
|
+
# A field becomes ``#<id>``; anything else is an htmx selector, used verbatim.
|
|
131
|
+
hx_target = f"#{target.id}" if kind == "field" else target_name
|
|
132
|
+
return {
|
|
133
|
+
"formnovalidate": True,
|
|
134
|
+
form.meta._htmx_request_attr: form.meta.htmx_endpoint,
|
|
135
|
+
"hx-target": hx_target,
|
|
136
|
+
"hx-swap": "outerHTML",
|
|
137
|
+
# htmx only adds the enclosing form for verbs other than GET/DELETE.
|
|
138
|
+
"hx-include": "closest form",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _warn_unbound_targets(holder: Any, targets: list[tuple]) -> None:
|
|
143
|
+
"""Warn about Python names that match no field and are not htmx keywords."""
|
|
144
|
+
for target_name, _entry, kind, _val in targets:
|
|
145
|
+
if kind == "unbound" and target_name not in _HTMX_TARGET_KEYWORDS:
|
|
146
|
+
warnings.warn(
|
|
147
|
+
f"{holder.name!r} triggers {target_name!r}, which matches no field. "
|
|
148
|
+
"It is used as a CSS selector. Check the field name, or write "
|
|
149
|
+
"the selector with a '#', '.' or other CSS syntax.",
|
|
150
|
+
stacklevel=2,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _wire_button(holder: Any, form: Any, triggers: dict[str, Any]) -> None:
|
|
155
|
+
"""Give a button the htmx attributes of its first target.
|
|
156
|
+
|
|
157
|
+
Raise ValueError when a declaration sets ``on=``: a button fires on click.
|
|
158
|
+
A choices field renders its buttons itself, with ``htmx_submit_attrs``.
|
|
159
|
+
"""
|
|
160
|
+
if any(entry.event for entry in triggers.values()):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"{holder.name!r} is a button: it fires on click, so on= does not apply."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if not isinstance(holder, SelectFieldBase):
|
|
166
|
+
attrs = _button_attrs(form, holder, next(iter(triggers)))
|
|
167
|
+
holder.render_kw = {**attrs, **(holder.render_kw or {})}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _collect_triggers(holder: Any, form: Any) -> None:
|
|
171
|
+
"""Record ``holder.triggers(...)`` declarations on their targets.
|
|
172
|
+
|
|
173
|
+
A field target gets an entry in ``_htmx_trigger_sources``. A selector
|
|
174
|
+
target stays on the source, in ``_htmx_selector_target``. A button sends
|
|
175
|
+
its own request: it gets its htmx attributes instead. Raise ValueError
|
|
176
|
+
when the source declares more than one selector target.
|
|
177
|
+
"""
|
|
178
|
+
triggers = _state(holder, "_htmx_triggers")
|
|
179
|
+
if not triggers:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
root = _root_of(form)
|
|
183
|
+
targets = [
|
|
184
|
+
(target_name, entry, *_classify_target(form, root, target_name))
|
|
185
|
+
for target_name, entry in triggers.items()
|
|
186
|
+
]
|
|
187
|
+
_warn_unbound_targets(holder, targets)
|
|
188
|
+
selectors = [name for name, _entry, kind, _val in targets if kind != "field"]
|
|
189
|
+
if len(selectors) > 1:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"{holder.name!r} declares several selector targets "
|
|
192
|
+
f"({', '.join(selectors)}). Declare one, and return the other "
|
|
193
|
+
"elements as out-of-band extras from its callback."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if _is_submit_source(holder):
|
|
197
|
+
_wire_button(holder, form, triggers)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
for target_name, entry, kind, val in targets:
|
|
201
|
+
if kind == "field":
|
|
202
|
+
_add_trigger_source(val, holder.id, holder.name, entry)
|
|
203
|
+
else:
|
|
204
|
+
# "selector", or a name matching no field: hand it to htmx
|
|
205
|
+
# verbatim so the source carries its own hx-target.
|
|
206
|
+
holder._htmx_selector_target = (target_name, entry)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _check_selector_sources(form: Any) -> None:
|
|
210
|
+
"""Raise ValueError when a field has a selector target and listens to another field.
|
|
211
|
+
|
|
212
|
+
Its requests could not both reach the selector and re-render the field.
|
|
213
|
+
"""
|
|
214
|
+
for field in _walk_fields(form):
|
|
215
|
+
if _state(field, "_htmx_selector_target") is None:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
sources = _state(field, "_htmx_trigger_sources") or []
|
|
219
|
+
if any(source_id != field.id for source_id, _name, _entry in sources):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"{field.name!r} has a selector target and listens to another "
|
|
222
|
+
"field. Keep one of the two, and update the other element as "
|
|
223
|
+
"an out-of-band extra from a callback."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _collect_triggered_by(holder: Any, form: Any) -> None:
|
|
228
|
+
"""Record ``holder.triggered_by(...)`` declarations in ``holder._htmx_trigger_sources``.
|
|
229
|
+
|
|
230
|
+
The wildcard is kept as ``("*", "*", entry)``. Its callback gets
|
|
231
|
+
``source=None``. Warn about a source that matches no field.
|
|
232
|
+
"""
|
|
233
|
+
triggered_by = _state(holder, "_htmx_triggered_by")
|
|
234
|
+
if not triggered_by:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
root = _root_of(form)
|
|
238
|
+
for source_name, entry in triggered_by.items():
|
|
239
|
+
if source_name == "*":
|
|
240
|
+
_add_trigger_source(holder, "*", "*", entry)
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
kind, val = _classify_target(form, root, source_name)
|
|
244
|
+
if kind != "field":
|
|
245
|
+
warnings.warn(
|
|
246
|
+
f"{holder.name!r} is triggered by {source_name!r}, which matches "
|
|
247
|
+
"no field. The declaration is ignored. Check the field name.",
|
|
248
|
+
stacklevel=2,
|
|
249
|
+
)
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
_add_trigger_source(holder, val.id, val.name, entry)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class HtmxFormMixin:
|
|
256
|
+
"""Mixin exposing htmx integration helpers on a WTForms form.
|
|
257
|
+
|
|
258
|
+
Mix it in first, before the other form base. WTForms merges the
|
|
259
|
+
``Meta`` classes of every base, and :class:`HtmxMeta` takes precedence
|
|
260
|
+
over the other base's ``Meta``::
|
|
261
|
+
|
|
262
|
+
class ProfileForm(HtmxFormMixin, FlaskForm):
|
|
263
|
+
email = EmailField()
|
|
264
|
+
country = StringField(datalist=DataList(...))
|
|
265
|
+
region = StringField().triggered_by("country", on="change")
|
|
266
|
+
|
|
267
|
+
The default :class:`HtmxMeta` adds htmx attributes on render, based
|
|
268
|
+
on form-level defaults. Trigger declarations go through
|
|
269
|
+
:meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` and
|
|
270
|
+
:meth:`.triggered_by() <wtforms.fields.core.UnboundField.triggered_by>`. The
|
|
271
|
+
mixin itself is framework-agnostic: it does not touch request or
|
|
272
|
+
response objects. Your view layer does that.
|
|
273
|
+
|
|
274
|
+
Pass the request headers to the constructor as ``headers=``, for
|
|
275
|
+
example ``ProfileForm(request.form or None, headers=request.headers)``.
|
|
276
|
+
Without them, the form does not detect htmx requests.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
Meta = HtmxMeta
|
|
280
|
+
|
|
281
|
+
if TYPE_CHECKING:
|
|
282
|
+
meta: HtmxMeta
|
|
283
|
+
_fields: dict[str, Field]
|
|
284
|
+
_parent_form: HtmxFormMixin | None
|
|
285
|
+
_htmx_mutation: _HtmxMutation | None
|
|
286
|
+
|
|
287
|
+
def __init__(self, *args, headers=None, **kwargs):
|
|
288
|
+
# Set before ``super().__init__``: post_process, which dispatches,
|
|
289
|
+
# runs inside it.
|
|
290
|
+
self._htmx_headers = headers
|
|
291
|
+
self._htmx_formdata = None
|
|
292
|
+
if "meta" in kwargs:
|
|
293
|
+
kwargs["meta"] = _with_parent_settings(type(self), kwargs["meta"])
|
|
294
|
+
|
|
295
|
+
super().__init__(*args, **kwargs)
|
|
296
|
+
# A nested form wires its triggers now: its post_process runs
|
|
297
|
+
# after the root's mutate.
|
|
298
|
+
if self._parent_form is not None:
|
|
299
|
+
self._htmx_wire_triggers()
|
|
300
|
+
|
|
301
|
+
def post_process(self, formdata=None):
|
|
302
|
+
"""Wire the trigger declarations, then dispatch the mutations.
|
|
303
|
+
|
|
304
|
+
Wire before ``super()`` so dispatch sees the declarations, and
|
|
305
|
+
dispatch after it so callbacks see resolved ``choices``. Nested forms
|
|
306
|
+
never dispatch.
|
|
307
|
+
"""
|
|
308
|
+
self._htmx_wire_triggers()
|
|
309
|
+
self._htmx_formdata = formdata
|
|
310
|
+
super().post_process(formdata)
|
|
311
|
+
if self._parent_form is None and self.meta.htmx_auto_mutate:
|
|
312
|
+
self.mutate()
|
|
313
|
+
|
|
314
|
+
def partial(self):
|
|
315
|
+
"""Build the htmx partial response for the current request, or ``None``.
|
|
316
|
+
|
|
317
|
+
Returns an :class:`~wtforms_htmx.HtmxResponse` carrying the body
|
|
318
|
+
and the optional htmx response headers (typically
|
|
319
|
+
:htmx-header:`hx-retarget`), or ``None`` when there's no partial
|
|
320
|
+
to produce: non-htmx request, :htmx-attr:`hx-boost` full-page swap, or
|
|
321
|
+
nothing to re-render.
|
|
322
|
+
|
|
323
|
+
Uses the request headers captured at form construction
|
|
324
|
+
(``Form(formdata, headers=request.headers)``).
|
|
325
|
+
|
|
326
|
+
This method renders. It does not run callbacks or change the
|
|
327
|
+
form's data: :meth:`mutate` does that, at construction. The
|
|
328
|
+
fragment is the re-render of the triggered mutation, the inline
|
|
329
|
+
validation result, or a datalist refresh.
|
|
330
|
+
|
|
331
|
+
Typical usage::
|
|
332
|
+
|
|
333
|
+
form = ProfileForm(request.form or None, headers=request.headers)
|
|
334
|
+
if result := form.partial():
|
|
335
|
+
return Response(result.body, headers=result.headers)
|
|
336
|
+
|
|
337
|
+
if form.submitter is form.save and form.validate():
|
|
338
|
+
save(form.data) # skipped on a no-JS mutation click
|
|
339
|
+
return redirect("/profile")
|
|
340
|
+
|
|
341
|
+
return render_template("page.html", form=form)
|
|
342
|
+
"""
|
|
343
|
+
return _compute_partial(self, self._htmx_headers)
|
|
344
|
+
|
|
345
|
+
def _htmx_wire_triggers(self) -> None:
|
|
346
|
+
"""Record every trigger declaration of the form once, then wire inline validation.
|
|
347
|
+
|
|
348
|
+
``post_process`` can run several times: a second collection would
|
|
349
|
+
duplicate the entries, but inline validation is wired again so that
|
|
350
|
+
list rows added since get it. Names that match no field are skipped.
|
|
351
|
+
"""
|
|
352
|
+
if not self.__dict__.get("_htmx_triggers_wired"):
|
|
353
|
+
self._htmx_triggers_wired = True
|
|
354
|
+
# The helpers record the qualified ``holder.name``, which dispatch
|
|
355
|
+
# needs inside a sub-form.
|
|
356
|
+
for holder in self._fields.values():
|
|
357
|
+
_collect_triggers(holder, self)
|
|
358
|
+
_collect_triggered_by(holder, self)
|
|
359
|
+
|
|
360
|
+
# Nested forms collect while the root is processed, so the root
|
|
361
|
+
# sees every declaration of the tree.
|
|
362
|
+
if self._parent_form is None:
|
|
363
|
+
_check_selector_sources(self)
|
|
364
|
+
|
|
365
|
+
if self.meta.htmx_inline_validation:
|
|
366
|
+
self._wire_inline_validation()
|
|
367
|
+
|
|
368
|
+
def _wire_inline_validation(self) -> None:
|
|
369
|
+
"""Declare inline validation as a self-trigger on every eligible field.
|
|
370
|
+
|
|
371
|
+
It covers the rows of a FieldList of plain fields. A FormField wires its
|
|
372
|
+
own form. Skipped: submit sources and fields with a selector target. A
|
|
373
|
+
select, and a list of radio buttons or checkboxes, validates on
|
|
374
|
+
``change``. The declaration is replaced each time, so a renumbered row
|
|
375
|
+
keeps its id.
|
|
376
|
+
"""
|
|
377
|
+
for holder in _inline_validation_fields(self._fields.values()):
|
|
378
|
+
if (
|
|
379
|
+
_is_submit_source(holder)
|
|
380
|
+
or _state(holder, "_htmx_selector_target") is not None
|
|
381
|
+
):
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
# ``blur`` never reaches the list container, ``change`` bubbles to
|
|
385
|
+
# it. A select is done once the user picks an option.
|
|
386
|
+
event = (
|
|
387
|
+
"change"
|
|
388
|
+
if isinstance(holder.widget, (ListWidget, Select))
|
|
389
|
+
else self.meta.htmx_validation_trigger
|
|
390
|
+
)
|
|
391
|
+
sources = _state(holder, "_htmx_trigger_sources") or []
|
|
392
|
+
holder._htmx_trigger_sources = [
|
|
393
|
+
source
|
|
394
|
+
for source in sources
|
|
395
|
+
if source[2].callback is not _inline_validate
|
|
396
|
+
]
|
|
397
|
+
_add_trigger_source(
|
|
398
|
+
holder, holder.id, holder.name, _TriggerEntry(event, _inline_validate)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _htmx_resolve(self, name: str):
|
|
402
|
+
"""Find a field by its HTML name, with or without the form's prefix.
|
|
403
|
+
|
|
404
|
+
Return ``None`` when ``name`` matches no field.
|
|
405
|
+
"""
|
|
406
|
+
prefix = getattr(self, "_prefix", "") or ""
|
|
407
|
+
if prefix and name.startswith(prefix):
|
|
408
|
+
name = name[len(prefix) :]
|
|
409
|
+
|
|
410
|
+
return _resolve_field(self, name)
|
|
411
|
+
|
|
412
|
+
def _htmx_resolve_datalist(self, datalist_id: str | None):
|
|
413
|
+
"""Find the field whose bound DataList has the id ``datalist_id``, or ``None``."""
|
|
414
|
+
if not datalist_id:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
return _resolve_field_by_datalist_id(self, datalist_id)
|
|
418
|
+
|
|
419
|
+
def htmx_submit_attrs(self, field: Any) -> dict[str, Any]:
|
|
420
|
+
"""Return the hx-* attributes a submit :mdn-tag:`button` needs to fire ``field``'s mutation.
|
|
421
|
+
|
|
422
|
+
A field declared with
|
|
423
|
+
:meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>` posts
|
|
424
|
+
the form and swaps its target through htmx attributes on the rendered
|
|
425
|
+
button. The stock :class:`~wtforms.fields.SubmitField` /
|
|
426
|
+
:class:`~wtforms.fields.ButtonField` widgets get them automatically.
|
|
427
|
+
This helper is for a **custom widget** that renders the
|
|
428
|
+
button(s) itself (typically a Jinja macro emitting one button
|
|
429
|
+
per choice) and has to thread the attributes on by hand:
|
|
430
|
+
|
|
431
|
+
.. code-block:: jinja
|
|
432
|
+
|
|
433
|
+
{%- macro listbox(field) -%}
|
|
434
|
+
<ul role="listbox" id="{{ field.id }}">
|
|
435
|
+
{%- for opt in field -%}
|
|
436
|
+
{%- set attrs = dict(opt.choice.render_kw,
|
|
437
|
+
**form.htmx_submit_attrs(opt)) -%}
|
|
438
|
+
{{ opt(label=opt.choice.label, **attrs) }}
|
|
439
|
+
{%- endfor -%}
|
|
440
|
+
</ul>
|
|
441
|
+
{%- endmacro %}
|
|
442
|
+
|
|
443
|
+
The dict carries :mdn-attr:`button#formnovalidate`, the request attribute
|
|
444
|
+
(:htmx-attr:`hx-post` by default, see :attr:`~wtforms_htmx.HtmxMeta.htmx_method`)
|
|
445
|
+
set to :attr:`~wtforms_htmx.HtmxMeta.htmx_endpoint`, :htmx-attr:`hx-target` (the first
|
|
446
|
+
:meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>`
|
|
447
|
+
target), :htmx-attr:`hx-swap` ``outerHTML`` and
|
|
448
|
+
:htmx-attr:`hx-include` ``closest form``. Merge it with
|
|
449
|
+
other attributes before splatting, as above. For a formatted
|
|
450
|
+
string, see :meth:`htmx_submit_params`.
|
|
451
|
+
|
|
452
|
+
Returns ``{}`` for a field with no trigger declaration. A select
|
|
453
|
+
option returns its parent field's attributes, so a macro can call
|
|
454
|
+
it on each option.
|
|
455
|
+
"""
|
|
456
|
+
triggers = _state(field, "_htmx_triggers")
|
|
457
|
+
# A choice option shares its name and its form with the choices field.
|
|
458
|
+
if not triggers and (parent := self._htmx_resolve(field.name)) is not None:
|
|
459
|
+
triggers = _state(parent, "_htmx_triggers")
|
|
460
|
+
|
|
461
|
+
if not triggers:
|
|
462
|
+
return {}
|
|
463
|
+
|
|
464
|
+
return _button_attrs(self, field, next(iter(triggers)))
|
|
465
|
+
|
|
466
|
+
def htmx_submit_params(self, field: Any) -> Markup:
|
|
467
|
+
"""Pre-formatted version of :meth:`htmx_submit_attrs`.
|
|
468
|
+
|
|
469
|
+
Wraps the computed dict through :func:`~wtforms.widgets.html_params`
|
|
470
|
+
and returns :class:`~markupsafe.Markup`, so templates can
|
|
471
|
+
splat it directly onto a :mdn-tag:`button` without iteration::
|
|
472
|
+
|
|
473
|
+
<button ... {{ form.htmx_submit_params(field) }}>
|
|
474
|
+
|
|
475
|
+
Returns empty :class:`~markupsafe.Markup` when ``field`` has
|
|
476
|
+
no :meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>`
|
|
477
|
+
declaration. For composition (merging
|
|
478
|
+
caller-side attrs), use :meth:`htmx_submit_attrs` and format
|
|
479
|
+
yourself.
|
|
480
|
+
"""
|
|
481
|
+
attrs = self.htmx_submit_attrs(field)
|
|
482
|
+
return Markup(html_params(**attrs)) if attrs else Markup("")
|
|
483
|
+
|
|
484
|
+
@property
|
|
485
|
+
def submitter(self) -> Field | None:
|
|
486
|
+
"""Return the button field that submitted the form, or ``None``.
|
|
487
|
+
|
|
488
|
+
Compare it to a field with ``is``: ``form.submitter is form.save``.
|
|
489
|
+
A :class:`~wtforms.fields.ButtonField` or
|
|
490
|
+
:class:`~wtforms.fields.SubmitField` nested in a
|
|
491
|
+
:class:`~wtforms.fields.FieldList` or
|
|
492
|
+
:class:`~wtforms.fields.FormField` is found too, as long as the
|
|
493
|
+
row form uses :class:`~wtforms_htmx.HtmxMeta`, as an
|
|
494
|
+
:class:`~wtforms_htmx.HtmxForm` or an :class:`~wtforms_htmx.HtmxFormMixin`
|
|
495
|
+
subclass does. A row button compares to its own entry. A ``prefix=`` on the form changes nothing.
|
|
496
|
+
|
|
497
|
+
Use it when a form has several submit buttons with different
|
|
498
|
+
meanings (save, delete, prefill…), and to tell a submit from a
|
|
499
|
+
mutation click. A button written in the template without a
|
|
500
|
+
``name`` leaves it at ``None``.
|
|
501
|
+
"""
|
|
502
|
+
for btn in _iter_submit_sources(self):
|
|
503
|
+
if btn.raw_data:
|
|
504
|
+
return btn
|
|
505
|
+
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
@property
|
|
509
|
+
def mutated(self) -> bool:
|
|
510
|
+
"""Return ``True`` when the request ran a mutation.
|
|
511
|
+
|
|
512
|
+
This is the case after a click on a button with
|
|
513
|
+
:meth:`.triggers() <wtforms.fields.core.UnboundField.triggers>`, with
|
|
514
|
+
or without JavaScript, and after an htmx request that re-renders a
|
|
515
|
+
field. A view checks it to skip saving::
|
|
516
|
+
|
|
517
|
+
if not form.mutated and form.validate():
|
|
518
|
+
save(form.data)
|
|
519
|
+
|
|
520
|
+
It stays ``False`` until :meth:`mutate` runs.
|
|
521
|
+
"""
|
|
522
|
+
mutation = self.__dict__.get("_htmx_mutation")
|
|
523
|
+
return mutation is not None and mutation.dispatched
|
|
524
|
+
|
|
525
|
+
def mutate(self) -> None:
|
|
526
|
+
"""Dispatch the request's mutations.
|
|
527
|
+
|
|
528
|
+
Runs the callbacks of the request and records which targets
|
|
529
|
+
re-render; :meth:`partial` does the rendering. It runs the
|
|
530
|
+
callbacks of the button that submitted the form, with or without
|
|
531
|
+
JavaScript. For an htmx request, it then runs the callbacks
|
|
532
|
+
declared towards the field that sent the request. See
|
|
533
|
+
:ref:`dispatch-path`. To tell a
|
|
534
|
+
mutation click from a submit in the view, read :attr:`submitter`.
|
|
535
|
+
|
|
536
|
+
You rarely call this in a view: by default it runs automatically
|
|
537
|
+
(:attr:`Meta.htmx_auto_mutate <HtmxMeta.htmx_auto_mutate>`,
|
|
538
|
+
default ``True``). Call it
|
|
539
|
+
explicitly when ``htmx_auto_mutate`` is off and the view must run
|
|
540
|
+
logic *between* construction and the mutation, such as
|
|
541
|
+
inspecting the raw submission, authorizing the clicked button, or
|
|
542
|
+
loading state the mutation builds on::
|
|
543
|
+
|
|
544
|
+
class TagsForm(HtmxForm):
|
|
545
|
+
class Meta:
|
|
546
|
+
htmx_auto_mutate = False # opt out of the automatic run
|
|
547
|
+
|
|
548
|
+
tags = FieldList(StringField())
|
|
549
|
+
add_tag = ButtonField("Add tag").triggers("tags", list_add)
|
|
550
|
+
|
|
551
|
+
form = TagsForm(request.form or None, headers=request.headers)
|
|
552
|
+
if not user_may(form.submitter): # gate the click before applying
|
|
553
|
+
abort(403)
|
|
554
|
+
|
|
555
|
+
form.mutate() # then run the declared mutations
|
|
556
|
+
|
|
557
|
+
It reads the request headers passed to the constructor, like
|
|
558
|
+
:meth:`partial`.
|
|
559
|
+
|
|
560
|
+
The first call runs the mutations. Later calls do nothing, so an
|
|
561
|
+
explicit call after the automatic one is harmless.
|
|
562
|
+
|
|
563
|
+
When a mutation fired, the form is post-processed again, so derived
|
|
564
|
+
state such as ``choices`` reflects the new data.
|
|
565
|
+
|
|
566
|
+
With CSRF protection on, a submitted form with an invalid token runs
|
|
567
|
+
no callback. The error is set on the CSRF field.
|
|
568
|
+
"""
|
|
569
|
+
if self.__dict__.get("_htmx_mutation") is not None:
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
# Stored even when empty: its presence marks that mutate has run.
|
|
573
|
+
mutation = _HtmxMutation()
|
|
574
|
+
self._htmx_mutation = mutation
|
|
575
|
+
if not self._htmx_csrf_valid():
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
self._dispatch_button(mutation)
|
|
579
|
+
self._dispatch_source_request(mutation)
|
|
580
|
+
if mutation.dispatched:
|
|
581
|
+
self.post_process(self._htmx_formdata)
|
|
582
|
+
|
|
583
|
+
def _htmx_csrf_valid(self) -> bool:
|
|
584
|
+
"""Check the CSRF token of a submitted form, when CSRF protection is on."""
|
|
585
|
+
if not self.meta.csrf or self._htmx_formdata is None:
|
|
586
|
+
return True
|
|
587
|
+
|
|
588
|
+
return self._fields[self.meta.csrf_field_name].validate(self)
|
|
589
|
+
|
|
590
|
+
def _dispatch_button(self, mutation: _HtmxMutation) -> None:
|
|
591
|
+
"""Run the callbacks of the button that submitted the form."""
|
|
592
|
+
button = self.submitter
|
|
593
|
+
if button is None or not (triggers := _state(button, "_htmx_triggers")):
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
host = getattr(button, "_form", None) or self
|
|
597
|
+
for target_name, entry in triggers.items():
|
|
598
|
+
kind, target = _classify_target(host, self, target_name)
|
|
599
|
+
if kind != "field":
|
|
600
|
+
# A selector, or a name matching no field: the callback owns the HTML.
|
|
601
|
+
mutation.selector_dispatched = True
|
|
602
|
+
if entry.callback is not None:
|
|
603
|
+
mutation.add_result(entry.callback(self, button, target))
|
|
604
|
+
|
|
605
|
+
continue
|
|
606
|
+
|
|
607
|
+
if entry.callback is not None:
|
|
608
|
+
self._run_field_callback(entry, button, target, mutation)
|
|
609
|
+
|
|
610
|
+
mutation.targets.append(target)
|
|
611
|
+
|
|
612
|
+
def _dispatch_source_request(self, mutation: _HtmxMutation) -> None:
|
|
613
|
+
"""Run the callbacks for the field that sent an htmx request.
|
|
614
|
+
|
|
615
|
+
A field with a selector target runs that callback with the selector
|
|
616
|
+
string as ``target``. A listening field runs every callback declared
|
|
617
|
+
towards it. Then the field runs the callbacks of the declarations it
|
|
618
|
+
posts itself, and their targets re-render out of band.
|
|
619
|
+
"""
|
|
620
|
+
if not self._htmx_headers:
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
source_id = _parse_request(self._htmx_headers).source_id
|
|
624
|
+
if (field := _resolve_source_field(self, source_id)) is None:
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
outgoing = self._source_posted_targets(field)
|
|
628
|
+
if _state(field, "_htmx_selector_target") is not None:
|
|
629
|
+
self._dispatch_selector(field, mutation)
|
|
630
|
+
elif _state(field, "_htmx_trigger_sources"):
|
|
631
|
+
self._dispatch_listener(field, mutation)
|
|
632
|
+
elif outgoing:
|
|
633
|
+
mutation.targets.append(field)
|
|
634
|
+
|
|
635
|
+
# Inline validation of the source validates the whole form, but the
|
|
636
|
+
# user has not visited the targets yet. A callback may validate them.
|
|
637
|
+
for target, _entry in outgoing:
|
|
638
|
+
_clear_errors(target)
|
|
639
|
+
|
|
640
|
+
for target, entry in outgoing:
|
|
641
|
+
if entry.callback is not None:
|
|
642
|
+
self._run_field_callback(entry, field, target, mutation)
|
|
643
|
+
|
|
644
|
+
mutation.targets.append(target)
|
|
645
|
+
|
|
646
|
+
def _source_posted_targets(self, source: Any) -> list[tuple[Any, Any]]:
|
|
647
|
+
"""Return the fields ``source`` feeds through a declaration it posts itself."""
|
|
648
|
+
found = []
|
|
649
|
+
for target in _walk_fields(self):
|
|
650
|
+
for source_id, source_name, entry in (
|
|
651
|
+
_state(target, "_htmx_trigger_sources") or []
|
|
652
|
+
):
|
|
653
|
+
if source_id == target.id or source_name != source.name:
|
|
654
|
+
continue
|
|
655
|
+
|
|
656
|
+
if _is_source_posted(source_name, entry):
|
|
657
|
+
found.append((target, entry))
|
|
658
|
+
|
|
659
|
+
return found
|
|
660
|
+
|
|
661
|
+
def _dispatch_listener(self, target: Any, mutation: _HtmxMutation) -> None:
|
|
662
|
+
"""Run every callback declared towards ``target`` and record it for re-render."""
|
|
663
|
+
for source_id, source_name, entry in _state(target, "_htmx_trigger_sources"):
|
|
664
|
+
if entry.callback is None:
|
|
665
|
+
continue
|
|
666
|
+
|
|
667
|
+
# The source posts this declaration itself.
|
|
668
|
+
if source_id != target.id and _is_source_posted(source_name, entry):
|
|
669
|
+
continue
|
|
670
|
+
|
|
671
|
+
# A wildcard has no single source: callbacks get ``source=None``.
|
|
672
|
+
if source_name == "*":
|
|
673
|
+
source = None
|
|
674
|
+
elif (source := self._htmx_resolve(source_name)) is None:
|
|
675
|
+
continue
|
|
676
|
+
|
|
677
|
+
self._run_field_callback(entry, source, target, mutation)
|
|
678
|
+
|
|
679
|
+
mutation.targets.append(target)
|
|
680
|
+
|
|
681
|
+
def _dispatch_selector(self, source: Any, mutation: _HtmxMutation) -> None:
|
|
682
|
+
"""Run the callback of ``source``'s selector target."""
|
|
683
|
+
mutation.selector_dispatched = True
|
|
684
|
+
selector, entry = _state(source, "_htmx_selector_target")
|
|
685
|
+
if entry.callback is not None:
|
|
686
|
+
mutation.add_result(entry.callback(self, source, selector))
|
|
687
|
+
|
|
688
|
+
def _run_field_callback(
|
|
689
|
+
self, entry: Any, source: Any, target: Any, mutation: _HtmxMutation
|
|
690
|
+
) -> None:
|
|
691
|
+
"""Run ``entry``'s callback on the field ``target``.
|
|
692
|
+
|
|
693
|
+
``raw_data`` is cleared only when the callback changed ``target.data``:
|
|
694
|
+
it would otherwise win over the new data, and a plain re-render keeps
|
|
695
|
+
what the user typed.
|
|
696
|
+
"""
|
|
697
|
+
before = target.data
|
|
698
|
+
mutation.add_result(entry.callback(self, source, target))
|
|
699
|
+
if target.data != before:
|
|
700
|
+
target.raw_data = None
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
_PARENT_SETTINGS = tuple(name for name in vars(HtmxMeta) if name.startswith("htmx_"))
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _with_parent_settings(form_class: Any, meta: Any) -> Any:
|
|
707
|
+
"""Add the htmx settings of the parent form that a sub-form does not set itself.
|
|
708
|
+
|
|
709
|
+
A sub-form gets a meta dict with its parent form. The settings go in
|
|
710
|
+
before the fields are bound, which reads some of them. WTForms passes
|
|
711
|
+
only the parent form to a sub-form, not its meta (wtforms#247 proposed
|
|
712
|
+
it, unmerged).
|
|
713
|
+
"""
|
|
714
|
+
if not isinstance(meta, dict) or (parent := meta.get("_parent_form")) is None:
|
|
715
|
+
return meta
|
|
716
|
+
|
|
717
|
+
meta_class = form_class._wtforms_meta
|
|
718
|
+
inherited = {
|
|
719
|
+
name: getattr(parent.meta, name)
|
|
720
|
+
for name in _PARENT_SETTINGS
|
|
721
|
+
if name not in meta
|
|
722
|
+
and hasattr(parent.meta, name)
|
|
723
|
+
and not _declares(meta_class, name)
|
|
724
|
+
}
|
|
725
|
+
return {**inherited, **meta}
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _declares(meta_class: Any, name: str) -> bool:
|
|
729
|
+
"""Tell whether a Meta class of the form, above HtmxMeta, sets ``name``."""
|
|
730
|
+
mro = meta_class.__mro__
|
|
731
|
+
return any(name in vars(klass) for klass in mro[: mro.index(HtmxMeta)])
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _clear_errors(field: Any) -> None:
|
|
735
|
+
"""Remove the validation errors of ``field`` and of the fields it holds."""
|
|
736
|
+
if isinstance(field, FormField):
|
|
737
|
+
for sub in field.form:
|
|
738
|
+
_clear_errors(sub)
|
|
739
|
+
|
|
740
|
+
return
|
|
741
|
+
|
|
742
|
+
if isinstance(field, FieldList):
|
|
743
|
+
for entry in field.entries:
|
|
744
|
+
_clear_errors(entry)
|
|
745
|
+
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
field.errors = []
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
class HtmxForm(HtmxFormMixin, Form):
|
|
752
|
+
"""Canonical :class:`~wtforms.form.Form` subclass with htmx helpers baked in.
|
|
753
|
+
|
|
754
|
+
Convenience composition of :class:`HtmxFormMixin` and
|
|
755
|
+
:class:`~wtforms.form.Form`, equivalent to::
|
|
756
|
+
|
|
757
|
+
class MyForm(HtmxFormMixin, Form):
|
|
758
|
+
...
|
|
759
|
+
|
|
760
|
+
Prefer this class as the base class for htmx-enabled forms.
|
|
761
|
+
"""
|