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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Built-in callbacks for trigger declarations.
|
|
2
|
+
|
|
3
|
+
Each callback takes ``(form, source, target)``. ``value_set``,
|
|
4
|
+
``value_cycle`` and ``list_move`` build such a callback. The ones for a
|
|
5
|
+
SelectMultipleField or a FieldList raise TypeError on another target type.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from wtforms.fields import FieldList
|
|
13
|
+
from wtforms.fields import SelectMultipleField
|
|
14
|
+
|
|
15
|
+
from .triggers import TriggerCallback
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _entry_is_padding(entry: Any) -> bool:
|
|
19
|
+
"""Whether ``entry`` comes from ``min_entries`` padding rather than formdata.
|
|
20
|
+
|
|
21
|
+
A padded field has ``raw_data == []``. FormField and FieldList entries
|
|
22
|
+
are checked through their sub-fields.
|
|
23
|
+
"""
|
|
24
|
+
if hasattr(entry, "form"):
|
|
25
|
+
return all(_entry_is_padding(f) for f in entry.form._fields.values())
|
|
26
|
+
|
|
27
|
+
if hasattr(entry, "entries"):
|
|
28
|
+
return all(_entry_is_padding(e) for e in entry.entries)
|
|
29
|
+
|
|
30
|
+
return entry.raw_data == []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _truncate_to_padding(field: FieldList) -> None:
|
|
34
|
+
"""Drop the trailing entries that come from ``min_entries`` padding.
|
|
35
|
+
|
|
36
|
+
Mutations then only see the entries the user submitted.
|
|
37
|
+
"""
|
|
38
|
+
while field.entries and _entry_is_padding(field.entries[-1]):
|
|
39
|
+
field.pop_entry()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require(target: Any, field_class: type, name: str) -> None:
|
|
43
|
+
"""Raise TypeError when ``target`` is not a ``field_class``."""
|
|
44
|
+
if not isinstance(target, field_class):
|
|
45
|
+
raise TypeError(
|
|
46
|
+
f"{name} requires {field_class.__name__}, got {type(target).__name__}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _source_value(source: Any) -> str:
|
|
51
|
+
"""Return the source's value: ``raw_data[0]`` stripped, or ``""``."""
|
|
52
|
+
raw = getattr(source, "raw_data", None)
|
|
53
|
+
return raw[0].strip() if raw else ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _source_index(source: Any) -> int | None:
|
|
57
|
+
"""Return the source's value as an integer, or ``None`` when it is not one."""
|
|
58
|
+
try:
|
|
59
|
+
return int(_source_value(source))
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def value_set(value: Any) -> TriggerCallback:
|
|
65
|
+
"""Return a mutation that overwrites ``target.data`` with ``value``.
|
|
66
|
+
|
|
67
|
+
Use it to clear a field (``value_set("")`` / ``value_set(None)``) or
|
|
68
|
+
to reset it to a default, for example from a "clear" button::
|
|
69
|
+
|
|
70
|
+
query = StringField()
|
|
71
|
+
clear = ButtonField("Clear").triggers("query", value_set(""))
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def _mutation(form: Any, source: Any, target: Any) -> None:
|
|
75
|
+
target.data = value
|
|
76
|
+
|
|
77
|
+
return _mutation
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def value_cycle(values: list[Any]) -> TriggerCallback:
|
|
81
|
+
"""Return a mutation that rotates ``target.data`` through ``values``.
|
|
82
|
+
|
|
83
|
+
Each firing advances ``target.data`` to the next entry, wrapping around
|
|
84
|
+
at the end. If the current ``target.data`` isn't in ``values`` (for
|
|
85
|
+
example, when the field is empty), the mutation resets to ``values[0]``.
|
|
86
|
+
|
|
87
|
+
Use it with a single :class:`~wtforms.fields.ButtonField` for status
|
|
88
|
+
toggles, view/edit modes or step indicators. No value is read from
|
|
89
|
+
the source. Raise :exc:`ValueError` when ``values`` is empty.
|
|
90
|
+
|
|
91
|
+
Cycle a status field from one button::
|
|
92
|
+
|
|
93
|
+
STATES = ["todo", "doing", "done"]
|
|
94
|
+
status = SelectField(choices=STATES)
|
|
95
|
+
cycle = ButtonField("·").triggers("status", value_cycle(STATES))
|
|
96
|
+
"""
|
|
97
|
+
if not values:
|
|
98
|
+
raise ValueError("value_cycle() needs at least one value")
|
|
99
|
+
|
|
100
|
+
def _mutation(form: Any, _source: Any, target: Any) -> None:
|
|
101
|
+
current = target.data or ""
|
|
102
|
+
if current not in values:
|
|
103
|
+
target.data = values[0]
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
target.data = values[(values.index(current) + 1) % len(values)]
|
|
107
|
+
|
|
108
|
+
return _mutation
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def value_add(form: Any, source: Any, target: Any) -> None:
|
|
112
|
+
"""Append the source's value to a :class:`~wtforms.fields.SelectMultipleField`'s data.
|
|
113
|
+
|
|
114
|
+
No deduplication: clicking a button whose value is already in
|
|
115
|
+
``target.data`` adds a duplicate entry. Filter at the source if
|
|
116
|
+
uniqueness matters (e.g. exclude already-selected values from the
|
|
117
|
+
candidate set).
|
|
118
|
+
|
|
119
|
+
Each catalog button carries its option's value::
|
|
120
|
+
|
|
121
|
+
tags = SelectMultipleField(choices=CATALOG)
|
|
122
|
+
add_tag = ButtonField("Add").triggers("tags", value_add)
|
|
123
|
+
|
|
124
|
+
Render one button per option and pass the value at render time:
|
|
125
|
+
|
|
126
|
+
.. code-block:: jinja
|
|
127
|
+
|
|
128
|
+
{% for value, label in form.tags.choices %}
|
|
129
|
+
{{ form.add_tag(value=value, label=label, id=False) }}
|
|
130
|
+
{% endfor %}
|
|
131
|
+
"""
|
|
132
|
+
_require(target, SelectMultipleField, "value_add")
|
|
133
|
+
|
|
134
|
+
value = _source_value(source)
|
|
135
|
+
if not value:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
target.data = [*list(target.data or []), value]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def value_remove(form: Any, source: Any, target: Any) -> None:
|
|
142
|
+
"""Remove the first occurrence of the source's value from a :class:`~wtforms.fields.SelectMultipleField`'s data.
|
|
143
|
+
|
|
144
|
+
Subsequent matches (if duplicates exist) are left untouched,
|
|
145
|
+
like :meth:`list.remove`.
|
|
146
|
+
|
|
147
|
+
Each chip "×" button carries its value::
|
|
148
|
+
|
|
149
|
+
tags = SelectMultipleField(choices=CATALOG)
|
|
150
|
+
rm_tag = ButtonField("×").triggers("tags", value_remove)
|
|
151
|
+
|
|
152
|
+
.. code-block:: jinja
|
|
153
|
+
|
|
154
|
+
{% for value in form.tags.data or [] %}
|
|
155
|
+
{{ value }} {{ form.rm_tag(value=value, id=False) }}
|
|
156
|
+
{% endfor %}
|
|
157
|
+
"""
|
|
158
|
+
_require(target, SelectMultipleField, "value_remove")
|
|
159
|
+
|
|
160
|
+
value = _source_value(source)
|
|
161
|
+
data = list(target.data or [])
|
|
162
|
+
if value in data:
|
|
163
|
+
data.remove(value)
|
|
164
|
+
|
|
165
|
+
target.data = data
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def list_add(form: Any, source: Any, target: Any) -> None:
|
|
169
|
+
"""Append or insert an entry into a :class:`~wtforms.fields.FieldList`.
|
|
170
|
+
|
|
171
|
+
The insertion position is read from the source's value (typically
|
|
172
|
+
the clicked button's ``value=``). An empty string appends a fresh
|
|
173
|
+
entry at the end of the list. An integer inserts at that absolute
|
|
174
|
+
index and shifts the following entries one slot to the right. Any
|
|
175
|
+
other value does nothing.
|
|
176
|
+
|
|
177
|
+
Append a row from an Add button::
|
|
178
|
+
|
|
179
|
+
tasks = FieldList(StringField())
|
|
180
|
+
add_task = ButtonField("+ Add task").triggers("tasks", list_add)
|
|
181
|
+
"""
|
|
182
|
+
_require(target, FieldList, "list_add")
|
|
183
|
+
|
|
184
|
+
_truncate_to_padding(target)
|
|
185
|
+
if not _source_value(source):
|
|
186
|
+
target.append_entry()
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
index = _source_index(source)
|
|
190
|
+
if index is not None:
|
|
191
|
+
target.insert_entry(index)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def list_remove(form: Any, source: Any, target: Any) -> None:
|
|
195
|
+
"""Remove an entry from a :class:`~wtforms.fields.FieldList`.
|
|
196
|
+
|
|
197
|
+
The position is read from the source's value: an empty string
|
|
198
|
+
pops the last entry, while an integer drops the entry at that
|
|
199
|
+
absolute index. Any other value does nothing. Remaining entries are
|
|
200
|
+
re-indexed contiguously. An index captured client-side before the
|
|
201
|
+
call is then stale.
|
|
202
|
+
|
|
203
|
+
Remove a row from a per-row × button::
|
|
204
|
+
|
|
205
|
+
tasks = FieldList(StringField())
|
|
206
|
+
rm_task = ButtonField("×").triggers("tasks", list_remove)
|
|
207
|
+
"""
|
|
208
|
+
_require(target, FieldList, "list_remove")
|
|
209
|
+
|
|
210
|
+
_truncate_to_padding(target)
|
|
211
|
+
index = _source_index(source) if _source_value(source) else len(target) - 1
|
|
212
|
+
if index is not None and 0 <= index < len(target):
|
|
213
|
+
target.pop_entry(index)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def list_clear(form: Any, _source: Any, target: Any) -> None:
|
|
217
|
+
"""Empty a :class:`~wtforms.fields.FieldList` of all its entries.
|
|
218
|
+
|
|
219
|
+
Entries added afterwards in the same request are numbered from
|
|
220
|
+
``0``.
|
|
221
|
+
|
|
222
|
+
Empty the whole list from one button::
|
|
223
|
+
|
|
224
|
+
tasks = FieldList(StringField())
|
|
225
|
+
clear = ButtonField("Clear all").triggers("tasks", list_clear)
|
|
226
|
+
"""
|
|
227
|
+
_require(target, FieldList, "list_clear")
|
|
228
|
+
|
|
229
|
+
target.entries = []
|
|
230
|
+
target.last_index = -1
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def list_reorder(form: Any, source: Any, target: Any) -> None:
|
|
234
|
+
"""Reorder a :class:`~wtforms.fields.FieldList` in the order given by the source's value.
|
|
235
|
+
|
|
236
|
+
The value is a comma-separated list of the *old* indices in their
|
|
237
|
+
new order, e.g. ``"0,2,1"`` rebuilds the list as
|
|
238
|
+
``[old[0], old[2], old[1]]``. The indices must cover
|
|
239
|
+
``range(len(target))`` exactly once. A partial, duplicate or
|
|
240
|
+
out-of-range permutation does nothing, so a stale client request
|
|
241
|
+
cannot silently truncate or duplicate entries.
|
|
242
|
+
|
|
243
|
+
A hidden field carries the new index order (written client-side)::
|
|
244
|
+
|
|
245
|
+
tasks = FieldList(StringField())
|
|
246
|
+
reorder = HiddenField().triggers("tasks", list_reorder, on="change")
|
|
247
|
+
"""
|
|
248
|
+
_require(target, FieldList, "list_reorder")
|
|
249
|
+
|
|
250
|
+
_truncate_to_padding(target)
|
|
251
|
+
value = _source_value(source)
|
|
252
|
+
if not value:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
order = [int(part) for part in value.split(",")]
|
|
257
|
+
except ValueError:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if sorted(order) != list(range(len(target.entries))):
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
_rebuild_in_order(target, order)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def list_move(offset: int) -> TriggerCallback:
|
|
267
|
+
"""Return a mutation that moves a :class:`~wtforms.fields.FieldList` entry by ``offset``.
|
|
268
|
+
|
|
269
|
+
The entry index is read from the source's value. ``-1`` moves the
|
|
270
|
+
entry one row up, ``+1`` one row down. A move past either end of
|
|
271
|
+
the list, a missing value or a value that is not an index does
|
|
272
|
+
nothing. Remaining entries are re-indexed contiguously.
|
|
273
|
+
|
|
274
|
+
Move a row from per-row ↑ / ↓ buttons, an alternative to dragging
|
|
275
|
+
that works without JavaScript::
|
|
276
|
+
|
|
277
|
+
tasks = FieldList(StringField())
|
|
278
|
+
move_up = ButtonField("↑").triggers("tasks", list_move(-1))
|
|
279
|
+
move_down = ButtonField("↓").triggers("tasks", list_move(+1))
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def _mutation(form: Any, source: Any, target: Any) -> None:
|
|
283
|
+
_require(target, FieldList, "list_move")
|
|
284
|
+
|
|
285
|
+
_truncate_to_padding(target)
|
|
286
|
+
idx = _source_index(source)
|
|
287
|
+
if idx is None:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
new = idx + offset
|
|
291
|
+
n = len(target.entries)
|
|
292
|
+
if not (0 <= idx < n and 0 <= new < n):
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
order = list(range(n))
|
|
296
|
+
order.insert(new, order.pop(idx))
|
|
297
|
+
_rebuild_in_order(target, order)
|
|
298
|
+
|
|
299
|
+
return _mutation
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _rebuild_in_order(target: FieldList, order: list[int]) -> None:
|
|
303
|
+
"""Rebuild ``target`` with its entries taken in ``order`` (old indices)."""
|
|
304
|
+
# Snapshot data in the desired order before draining: pop_entry
|
|
305
|
+
# mutates entries in place and reindexes after each call.
|
|
306
|
+
data_in_order = [target.entries[i].data for i in order]
|
|
307
|
+
for _ in range(len(order)):
|
|
308
|
+
target.pop_entry()
|
|
309
|
+
|
|
310
|
+
for data in data_in_order:
|
|
311
|
+
target.append_entry(data)
|
wtforms_htmx/partial.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Build the partial response of a form, as an ``HtmxResponse``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from copy import copy
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from dataclasses import field
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
from typing import Any
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from markupsafe import Markup
|
|
14
|
+
from wtforms.fields import Field
|
|
15
|
+
|
|
16
|
+
from .headers import _HtmxRequestInfo
|
|
17
|
+
from .headers import _parse_request
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .form import HtmxFormMixin
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class HtmxResponse:
|
|
25
|
+
"""An htmx partial response.
|
|
26
|
+
|
|
27
|
+
Pairs the body to return with any htmx response headers the
|
|
28
|
+
library computed for it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
body: str
|
|
32
|
+
"""The rendered HTML fragment to send back to the client."""
|
|
33
|
+
|
|
34
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
35
|
+
"""htmx response headers the library computed for this response.
|
|
36
|
+
|
|
37
|
+
The library sets :htmx-header:`hx-retarget` (when the fragment's
|
|
38
|
+
outer element is not the element htmx targeted) and
|
|
39
|
+
:htmx-header:`hx-reswap` (``none`` when a selector target's callback
|
|
40
|
+
returns no body). Add any other htmx response header yourself
|
|
41
|
+
(:htmx-header:`hx-trigger` to fire a client event,
|
|
42
|
+
:htmx-header:`hx-redirect`, :htmx-header:`hx-refresh`,
|
|
43
|
+
:htmx-header:`hx-push-url`, :htmx-header:`hx-reselect`, …) by merging
|
|
44
|
+
it into a new dict when you build the framework response::
|
|
45
|
+
|
|
46
|
+
if result := form.partial():
|
|
47
|
+
return Response(
|
|
48
|
+
result.body,
|
|
49
|
+
headers={**result.headers, "HX-Trigger": "saved"},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
The mapping may be read-only, so do not assign into it.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class _HtmxMutation:
|
|
58
|
+
"""Render plan of one request: the targets to swap, a primary override, OOB extras.
|
|
59
|
+
|
|
60
|
+
Dispatch records the targets and the callback results. The plan applies
|
|
61
|
+
the callback return-value contract (see the API reference).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
targets: list[Any] = field(default_factory=list)
|
|
65
|
+
primary_override: Markup | None = None
|
|
66
|
+
primary_field: Any = None
|
|
67
|
+
oob_extras: list[Any] = field(default_factory=list)
|
|
68
|
+
selector_dispatched: bool = False
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def dispatched(self) -> bool:
|
|
72
|
+
"""Whether a trigger fired and left something to render."""
|
|
73
|
+
return bool(
|
|
74
|
+
self.targets
|
|
75
|
+
or self.primary_override is not None
|
|
76
|
+
or self.primary_field is not None
|
|
77
|
+
or self.selector_dispatched
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def add_result(self, result: Any) -> None:
|
|
81
|
+
"""Record a callback's return value.
|
|
82
|
+
|
|
83
|
+
Check for a Field before an iterable: SelectFieldBase and FieldList are
|
|
84
|
+
iterable too.
|
|
85
|
+
"""
|
|
86
|
+
if result is None:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
if isinstance(result, Field):
|
|
90
|
+
self.primary_field = result
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if isinstance(result, (str, Markup)):
|
|
94
|
+
self.primary_override = Markup(result)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
items = list(result)
|
|
98
|
+
if not items:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
primary, *rest = items
|
|
102
|
+
if isinstance(primary, (str, Markup)):
|
|
103
|
+
self.primary_override = Markup(primary)
|
|
104
|
+
elif isinstance(primary, Field):
|
|
105
|
+
self.primary_field = primary
|
|
106
|
+
|
|
107
|
+
# primary is None → default render kept (no override set).
|
|
108
|
+
self.oob_extras.extend(rest)
|
|
109
|
+
|
|
110
|
+
def split_targets(self) -> tuple[Any, list[Any]]:
|
|
111
|
+
"""Return the primary field, if any, and the targets rendered out-of-band.
|
|
112
|
+
|
|
113
|
+
A callback override replaces ``targets[0]``, unless a selector was
|
|
114
|
+
dispatched: then every target is out-of-band.
|
|
115
|
+
"""
|
|
116
|
+
if self.primary_override is None and self.primary_field is None:
|
|
117
|
+
return (self.targets[0] if self.targets else None), self.targets[1:]
|
|
118
|
+
|
|
119
|
+
rest = self.targets if self.selector_dispatched else self.targets[1:]
|
|
120
|
+
return self.primary_field, [t for t in rest if t is not self.primary_field]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _OuterTagParser(HTMLParser):
|
|
124
|
+
"""Locate the first opening tag of a fragment.
|
|
125
|
+
|
|
126
|
+
``tag`` stays ``None`` when the fragment has no tag. ``end`` is the
|
|
127
|
+
offset just past the tag's ``>``.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, html: str) -> None:
|
|
131
|
+
super().__init__()
|
|
132
|
+
self.tag: str | None = None
|
|
133
|
+
self.attrs: dict[str, str | None] = {}
|
|
134
|
+
self.end = 0
|
|
135
|
+
# getpos() counts lines on "\n" only, unlike str.splitlines().
|
|
136
|
+
self._lines = html.split("\n")
|
|
137
|
+
self.feed(html)
|
|
138
|
+
|
|
139
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
140
|
+
if self.tag is not None:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
self.tag = tag
|
|
144
|
+
self.attrs = dict(attrs)
|
|
145
|
+
lineno, col = self.getpos()
|
|
146
|
+
start = sum(len(line) + 1 for line in self._lines[: lineno - 1]) + col
|
|
147
|
+
self.end = start + len(self.get_starttag_text() or "")
|
|
148
|
+
|
|
149
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
150
|
+
self.handle_starttag(tag, attrs)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _extract_outer_id(html: str) -> str | None:
|
|
154
|
+
"""Return the ``id`` of the first opening tag in ``html``, if any."""
|
|
155
|
+
return _OuterTagParser(html).attrs.get("id") or None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _with_swap_oob(html: str) -> str:
|
|
159
|
+
"""Add ``hx-swap-oob="outerHTML"`` to the outer element of an OOB fragment.
|
|
160
|
+
|
|
161
|
+
Left untouched: a fragment that already sets ``hx-swap-oob``, and a
|
|
162
|
+
<template> wrapper. Raise ValueError when the outer element has no id.
|
|
163
|
+
"""
|
|
164
|
+
outer = _OuterTagParser(html)
|
|
165
|
+
if outer.tag is None or outer.tag == "template":
|
|
166
|
+
return html
|
|
167
|
+
|
|
168
|
+
if "hx-swap-oob" in outer.attrs or "data-hx-swap-oob" in outer.attrs:
|
|
169
|
+
return html
|
|
170
|
+
|
|
171
|
+
if not outer.attrs.get("id"):
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"An out-of-band HTML fragment needs an id on its outer element: {html!r}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
head = html[: outer.end]
|
|
177
|
+
close = "/>" if head.endswith("/>") else ">"
|
|
178
|
+
head = head[: -len(close)].rstrip()
|
|
179
|
+
return f'{head} hx-swap-oob="outerHTML"{close}{html[outer.end :]}'
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _render_mutation_target(field: Any, *, oob: bool) -> str:
|
|
183
|
+
"""Render one field for a partial response, through its wrapper.
|
|
184
|
+
|
|
185
|
+
An out-of-band field gets ``hx-swap-oob`` on the outer element of its
|
|
186
|
+
rendering, so the whole wrapper is swapped. A ``hide_value`` widget
|
|
187
|
+
(PasswordInput) shows the typed value during this render, so the swap
|
|
188
|
+
does not erase it.
|
|
189
|
+
"""
|
|
190
|
+
html = _render_revealed(field)
|
|
191
|
+
return _with_swap_oob(html) if oob else html
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _render_revealed(field: Any) -> str:
|
|
195
|
+
"""Render ``field``, with the value of a ``hide_value`` widget shown."""
|
|
196
|
+
widget = getattr(field, "widget", None)
|
|
197
|
+
if not (getattr(widget, "hide_value", False) and field.data):
|
|
198
|
+
return str(field.meta.render_field(field, {}))
|
|
199
|
+
|
|
200
|
+
# The widget is shared by every field of the class: reveal a copy.
|
|
201
|
+
field.widget = copy(widget)
|
|
202
|
+
field.widget.hide_value = False
|
|
203
|
+
try:
|
|
204
|
+
return str(field.meta.render_field(field, {}))
|
|
205
|
+
finally:
|
|
206
|
+
field.widget = widget
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _render_oob(form: HtmxFormMixin, extras: list[Any]) -> Markup:
|
|
210
|
+
"""Render out-of-band extras, each once.
|
|
211
|
+
|
|
212
|
+
Fields are deduplicated by identity, HTML fragments by outer id.
|
|
213
|
+
"""
|
|
214
|
+
html = Markup("")
|
|
215
|
+
seen: set[Any] = set()
|
|
216
|
+
for extra in extras:
|
|
217
|
+
is_html = isinstance(extra, (str, Markup))
|
|
218
|
+
key = _extract_outer_id(str(extra)) if is_html else id(extra)
|
|
219
|
+
if key is not None and key in seen:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
seen.add(key)
|
|
223
|
+
if is_html:
|
|
224
|
+
html += Markup(_with_swap_oob(str(extra)))
|
|
225
|
+
else:
|
|
226
|
+
html += Markup(_render_mutation_target(extra, oob=True))
|
|
227
|
+
|
|
228
|
+
return html
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _render_mutation_response(
|
|
232
|
+
form: HtmxFormMixin,
|
|
233
|
+
mutation: _HtmxMutation,
|
|
234
|
+
request: _HtmxRequestInfo,
|
|
235
|
+
) -> HtmxResponse:
|
|
236
|
+
"""Build the response for a dispatched mutation.
|
|
237
|
+
|
|
238
|
+
The primary swap is, in order: the callback's HTML, the callback's field,
|
|
239
|
+
the first target. The other targets and the callback's extras are
|
|
240
|
+
out-of-band. Without a primary swap, the body only holds the extras, with
|
|
241
|
+
``HX-Reswap: none`` and no ``HX-Retarget``.
|
|
242
|
+
"""
|
|
243
|
+
response_headers: dict[str, str] = {}
|
|
244
|
+
primary_field, rest = mutation.split_targets()
|
|
245
|
+
# Keep the body a Markup: ``Markup.__radd__`` escapes a plain ``str``.
|
|
246
|
+
if mutation.primary_override is not None:
|
|
247
|
+
body = mutation.primary_override
|
|
248
|
+
elif primary_field is not None:
|
|
249
|
+
body = Markup(_render_mutation_target(primary_field, oob=False))
|
|
250
|
+
else:
|
|
251
|
+
oob = _render_oob(form, rest + mutation.oob_extras)
|
|
252
|
+
return HtmxResponse(body=str(oob), headers={"HX-Reswap": "none"})
|
|
253
|
+
|
|
254
|
+
body += _render_oob(form, rest + mutation.oob_extras)
|
|
255
|
+
if retarget := _compute_retarget(str(body), request):
|
|
256
|
+
response_headers["HX-Retarget"] = retarget
|
|
257
|
+
|
|
258
|
+
return HtmxResponse(body=str(body), headers=response_headers)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _compute_partial(
|
|
262
|
+
form: HtmxFormMixin,
|
|
263
|
+
headers: Mapping[str, str] | None,
|
|
264
|
+
) -> HtmxResponse | None:
|
|
265
|
+
"""Render the mutation plan, or a datalist refresh.
|
|
266
|
+
|
|
267
|
+
Return ``None`` for a non-htmx or boosted request, or when there is
|
|
268
|
+
nothing to render.
|
|
269
|
+
"""
|
|
270
|
+
request = _parse_request(headers)
|
|
271
|
+
if not request.htmx or request.boosted:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
mutation = getattr(form, "_htmx_mutation", None)
|
|
275
|
+
if mutation is not None and mutation.dispatched:
|
|
276
|
+
return _render_mutation_response(form, mutation, request)
|
|
277
|
+
|
|
278
|
+
return _compute_datalist_partial(form, request)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _compute_datalist_partial(
|
|
282
|
+
form: HtmxFormMixin,
|
|
283
|
+
request: _HtmxRequestInfo,
|
|
284
|
+
) -> HtmxResponse | None:
|
|
285
|
+
"""Render the datalist that sent the request, alone.
|
|
286
|
+
|
|
287
|
+
Its ``hx-select`` picks the ``<option>`` children. No validation runs.
|
|
288
|
+
"""
|
|
289
|
+
field = form._htmx_resolve_datalist(request.source_id)
|
|
290
|
+
if field is None:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
return HtmxResponse(body=str(field.datalist()), headers={})
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _compute_retarget(body: str, request: _HtmxRequestInfo) -> str | None:
|
|
297
|
+
"""Return an ``hx-retarget`` selector for the fragment's outer id.
|
|
298
|
+
|
|
299
|
+
``None`` when the fragment has no outer id, or when htmx already
|
|
300
|
+
targets that element.
|
|
301
|
+
"""
|
|
302
|
+
if not (outer_id := _extract_outer_id(body)):
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
if request.target_id == outer_id:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
return f"#{outer_id}"
|
wtforms_htmx/py.typed
ADDED
|
File without changes
|