django-nestingdolls 1.0.0__tar.gz
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.
- django_nestingdolls-1.0.0/AGENTS.md +49 -0
- django_nestingdolls-1.0.0/CHANGELOG.md +5 -0
- django_nestingdolls-1.0.0/LICENSE +29 -0
- django_nestingdolls-1.0.0/MANIFEST.in +8 -0
- django_nestingdolls-1.0.0/PKG-INFO +497 -0
- django_nestingdolls-1.0.0/README.md +466 -0
- django_nestingdolls-1.0.0/django_nestingdolls.egg-info/PKG-INFO +497 -0
- django_nestingdolls-1.0.0/django_nestingdolls.egg-info/SOURCES.txt +44 -0
- django_nestingdolls-1.0.0/django_nestingdolls.egg-info/dependency_links.txt +1 -0
- django_nestingdolls-1.0.0/django_nestingdolls.egg-info/requires.txt +1 -0
- django_nestingdolls-1.0.0/django_nestingdolls.egg-info/top_level.txt +1 -0
- django_nestingdolls-1.0.0/nestingdolls/AGENTS.md +154 -0
- django_nestingdolls-1.0.0/nestingdolls/README.md +416 -0
- django_nestingdolls-1.0.0/nestingdolls/__init__.py +54 -0
- django_nestingdolls-1.0.0/nestingdolls/apps.py +17 -0
- django_nestingdolls-1.0.0/nestingdolls/boundfield.py +515 -0
- django_nestingdolls-1.0.0/nestingdolls/errors.py +91 -0
- django_nestingdolls-1.0.0/nestingdolls/fields.py +873 -0
- django_nestingdolls-1.0.0/nestingdolls/patches.py +123 -0
- django_nestingdolls-1.0.0/nestingdolls/py.typed +0 -0
- django_nestingdolls-1.0.0/nestingdolls/static/AGENTS.md +184 -0
- django_nestingdolls-1.0.0/nestingdolls/static/nestingdolls/sequence.js +311 -0
- django_nestingdolls-1.0.0/nestingdolls/static/nestingdolls/sequence.ts +466 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/AGENTS.md +52 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/mapping/attrs.html +4 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/mapping/div.html +15 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/mapping/p.html +26 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/mapping/table.html +19 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/mapping/ul.html +17 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/actions.html +33 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/deleted_rows.html +10 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/div.html +34 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/help_text.html +4 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/hidden.html +6 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/p.html +38 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/row.html +22 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/row_content.html +26 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/table.html +36 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/sequence/ul.html +34 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/shared/errors.html +4 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/shared/field_label.html +7 -0
- django_nestingdolls-1.0.0/nestingdolls/templates/nestingdolls/shared/help_text.html +8 -0
- django_nestingdolls-1.0.0/nestingdolls/widgets.py +975 -0
- django_nestingdolls-1.0.0/pyproject.toml +120 -0
- django_nestingdolls-1.0.0/setup.cfg +4 -0
- django_nestingdolls-1.0.0/tests/AGENTS.md +145 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
## Scope and guides
|
|
2
|
+
|
|
3
|
+
This repository-wide guide applies everywhere; read it before the guide for the
|
|
4
|
+
directory you edit. A nested guide supplements, rather than replaces, this one.
|
|
5
|
+
|
|
6
|
+
- Before package Python edits, read
|
|
7
|
+
[`nestingdolls/AGENTS.md`](nestingdolls/AGENTS.md); read its **Input limits**
|
|
8
|
+
section before changing normalization, extraction, or row counts.
|
|
9
|
+
- Before creating or changing a test, read
|
|
10
|
+
[`tests/AGENTS.md`](tests/AGENTS.md), the canonical test guide.
|
|
11
|
+
- Before editing `sequence.ts` or `tests/test_sequence.mjs`, read
|
|
12
|
+
[`nestingdolls/static/AGENTS.md`](nestingdolls/static/AGENTS.md).
|
|
13
|
+
- Before editing a Django template, read
|
|
14
|
+
[`nestingdolls/templates/AGENTS.md`](nestingdolls/templates/AGENTS.md).
|
|
15
|
+
|
|
16
|
+
## Layout
|
|
17
|
+
|
|
18
|
+
- `nestingdolls/` contains fields, widgets, bound fields, errors, and the
|
|
19
|
+
form-layout render patch; its guide maps modules to concerns and tests.
|
|
20
|
+
`nestingdolls/static/nestingdolls/` contains `sequence.ts` and its committed
|
|
21
|
+
compiled artifact, `sequence.js`; `nestingdolls/templates/nestingdolls/`
|
|
22
|
+
contains widget templates.
|
|
23
|
+
- `tests/` contains Python tests (`test_*.py`) and the jsdom tests
|
|
24
|
+
(`test_sequence.mjs`). The root also contains `demo.py`, `pathological.py`
|
|
25
|
+
(hostile-submission cost measurements), and `mypy_settings.py`.
|
|
26
|
+
|
|
27
|
+
## Commands and verification
|
|
28
|
+
|
|
29
|
+
All commands below are Make targets; use `make help` to list them. A fresh
|
|
30
|
+
clone needs `npm ci` before JavaScript targets (`tscheck`, `jsdrift`, `jstest`,
|
|
31
|
+
`js`) and therefore before `make check`.
|
|
32
|
+
|
|
33
|
+
- `make check` is the non-mutating CI gate: `tscheck`, `jsdrift`, `jstest`,
|
|
34
|
+
`ruff`, `formatcheck`, `test`, `distcheck`, and `mypy`.
|
|
35
|
+
- `make fix` applies lint auto-fixes and formatting; `make check` changes no
|
|
36
|
+
files.
|
|
37
|
+
- Run `make check` after implementation changes. Do not run runtime checks for
|
|
38
|
+
prose-only changes unless executable examples changed.
|
|
39
|
+
- `make mypy` checks `demo.py` and `nestingdolls` using `mypy_settings.py` for
|
|
40
|
+
`django-stubs`; tests do not import that file. `make ruff` and
|
|
41
|
+
`make formatcheck` cover the maintained Python files in the Makefile's
|
|
42
|
+
`PYTHON_FILES`.
|
|
43
|
+
- `make distcheck` builds both distributions from the tracked tree in a
|
|
44
|
+
temporary directory, runs `twine check --strict`, and verifies that the
|
|
45
|
+
sdist includes `LICENSE`; it uses `git write-tree`, so untracked build inputs
|
|
46
|
+
fail the gate.
|
|
47
|
+
- After editing `sequence.ts`, follow the static guide's build order.
|
|
48
|
+
`make check` compares `sequence.js` with the current TypeScript build using
|
|
49
|
+
`cmp`, not Git.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Keryn Knight
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-nestingdolls
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Composite Django form fields for nested mappings and sequences.
|
|
5
|
+
Author-email: Keryn Knight <keryn@kerynknight.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/kezabelle/django-nestingdolls
|
|
8
|
+
Project-URL: Source, https://github.com/kezabelle/django-nestingdolls
|
|
9
|
+
Project-URL: Issues, https://github.com/kezabelle/django-nestingdolls/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/kezabelle/django-nestingdolls/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: django,forms,formsets,fields,nested,composite
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 5.2
|
|
16
|
+
Classifier: Framework :: Django :: 6.0
|
|
17
|
+
Classifier: Framework :: Django :: 6.1
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.12
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: django>=5.2
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
# nestingdolls
|
|
35
|
+
|
|
36
|
+
Django's ordinary form fields are good for flat data, but fall _**flat**_ (hah!)
|
|
37
|
+
as soon as you might want any nesting, forcing instead toward separate formset
|
|
38
|
+
configuration and orchestration. It'd be nicer if it were simpler, and more
|
|
39
|
+
declarative...
|
|
40
|
+
|
|
41
|
+
You know that thing where a form has collected an address, an order, or a
|
|
42
|
+
schedule, then hands you a flat dictionary and says, good luck with that?
|
|
43
|
+
It works, but the awkward part is that your application has to put the related
|
|
44
|
+
keys back together.
|
|
45
|
+
|
|
46
|
+
`django-nestingdolls` does that bit for you. `DictField` renders and cleans a
|
|
47
|
+
child Django `Form`, then returns its cleaned values as one `dict`. `ListField`
|
|
48
|
+
renders formset-shaped rows for one child Django `Field`, then returns their
|
|
49
|
+
cleaned values as one `list`. The outer form gets the value it was describing
|
|
50
|
+
in the first place.
|
|
51
|
+
|
|
52
|
+
The child forms and fields still do the grown-up work: widgets, type
|
|
53
|
+
conversion, validation, file uploads, and errors. These fields just make sure
|
|
54
|
+
the bits that belong together don't wander off and become loose keys.
|
|
55
|
+
|
|
56
|
+
## A micro example
|
|
57
|
+
|
|
58
|
+
Here's a small snapshot of how simple it is, with a form which allows for
|
|
59
|
+
multiple subforms:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
|
|
63
|
+
# normal Django form!
|
|
64
|
+
class LineItemForm(forms.Form):
|
|
65
|
+
description = forms.CharField()
|
|
66
|
+
quantity = forms.IntegerField(min_value=1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ListOfMappingsForm(forms.Form):
|
|
70
|
+
# normal Django field
|
|
71
|
+
delivery_method = forms.ChoiceField(choices=[("standard", "Standard"), ("express", "Express")])
|
|
72
|
+
# normal Django field
|
|
73
|
+
contact_email = forms.EmailField()
|
|
74
|
+
# here's our special magic new fields!
|
|
75
|
+
items = nestingdolls.ListField(nestingdolls.DictField(LineItemForm), min_length=1, max_length=5)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
and that small definition gives us:
|
|
79
|
+
|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
(this example is taken from the git repository's `demo.py`)
|
|
83
|
+
|
|
84
|
+
## Install
|
|
85
|
+
|
|
86
|
+
`django-nestingdolls` expects Python 3.12+ and Django 5.2+.
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
python -m pip install django-nestingdolls
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
or add it to your `pyproject` and `uv sync` or whatever, poetry? pdm? you
|
|
93
|
+
do you.
|
|
94
|
+
|
|
95
|
+
### Setup
|
|
96
|
+
|
|
97
|
+
Then add the app to your Django settings module:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
INSTALLED_APPS = [
|
|
101
|
+
# ...
|
|
102
|
+
'nestingdolls',
|
|
103
|
+
]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
That's the whole thing, hopefully. Django's normal DTL form renderer finds
|
|
107
|
+
the package templates and the matching composite wrappers for the `as_div()`,
|
|
108
|
+
`as_p()`, `as_table()`, and `as_ul()` form helpers.
|
|
109
|
+
|
|
110
|
+
The app setup also makes composite wrappers follow Django's form-helper
|
|
111
|
+
layout. [Why that needs a patch](#the-render-patch).
|
|
112
|
+
|
|
113
|
+
## A quick tutorial
|
|
114
|
+
|
|
115
|
+
Let's make a `CheckoutForm`. The browser will send ordinary Django input
|
|
116
|
+
names; the finished form will hand us one nested `order` dictionary with
|
|
117
|
+
integer product IDs and quantities. Nice and tidy.
|
|
118
|
+
|
|
119
|
+
### Describe the order
|
|
120
|
+
|
|
121
|
+
Start small: a line item has two named values. An order has a customer ID and
|
|
122
|
+
as many of those items as it needs. Put the forms together like this:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from django import forms
|
|
126
|
+
|
|
127
|
+
import nestingdolls
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class LineItemForm(forms.Form):
|
|
131
|
+
product_id = forms.IntegerField(min_value=1)
|
|
132
|
+
quantity = forms.IntegerField(min_value=1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class OrderFieldsForm(forms.Form):
|
|
136
|
+
customer_id = forms.IntegerField(min_value=1) # Obviously not really :)
|
|
137
|
+
items = nestingdolls.ListField(
|
|
138
|
+
nestingdolls.DictField(LineItemForm),
|
|
139
|
+
min_length=1,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CheckoutForm(forms.Form):
|
|
144
|
+
order = nestingdolls.DictField(OrderFieldsForm)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`LineItemForm` checks products and quantities. `OrderFieldsForm` checks the
|
|
148
|
+
customer and item list. `CheckoutForm` gets one `order` value back, not a
|
|
149
|
+
scavenger hunt through keys that merely look like they are related.
|
|
150
|
+
|
|
151
|
+
### Bind browser data
|
|
152
|
+
|
|
153
|
+
Browsers only send strings, and nested child fields get prefixed names.
|
|
154
|
+
Sequence rows bring Django's usual formset management values along too. Here is
|
|
155
|
+
what a complete two-item submission looks like:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from django.http import QueryDict
|
|
159
|
+
|
|
160
|
+
# This is the data the user submitted for their order form from the
|
|
161
|
+
# previous section above.
|
|
162
|
+
submission = QueryDict(
|
|
163
|
+
'order-customer_id=17&'
|
|
164
|
+
'order-items-TOTAL_FORMS=2&'
|
|
165
|
+
'order-items-INITIAL_FORMS=0&'
|
|
166
|
+
'order-items-MIN_NUM_FORMS=0&'
|
|
167
|
+
'order-items-MAX_NUM_FORMS=1000&'
|
|
168
|
+
'order-items-0-product_id=42&'
|
|
169
|
+
'order-items-0-quantity=2&'
|
|
170
|
+
'order-items-1-product_id=73&'
|
|
171
|
+
'order-items-1-quantity=1'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
form = CheckoutForm(submission)
|
|
175
|
+
|
|
176
|
+
assert form.is_valid()
|
|
177
|
+
assert form.cleaned_data == {
|
|
178
|
+
'order': {
|
|
179
|
+
'customer_id': 17, # example only :D
|
|
180
|
+
'items': [
|
|
181
|
+
{'product_id': 42, 'quantity': 2},
|
|
182
|
+
{'product_id': 73, 'quantity': 1},
|
|
183
|
+
],
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
See the hand-off? `IntegerField` turns `order-items-0-quantity` from a request
|
|
189
|
+
string into an integer. `CheckoutForm` then returns one `order` value ready for
|
|
190
|
+
order code. No reconstruction helper, properly nested into conceptual
|
|
191
|
+
namespaces, less hunting around hopefully.
|
|
192
|
+
|
|
193
|
+
### Render it
|
|
194
|
+
|
|
195
|
+
Render it with Django's normal form helpers:
|
|
196
|
+
|
|
197
|
+
```django
|
|
198
|
+
<form method='post'>
|
|
199
|
+
{% csrf_token %}
|
|
200
|
+
{{ form.media }}
|
|
201
|
+
{{ form.as_div }}
|
|
202
|
+
<button type='submit'>Place order</button>
|
|
203
|
+
</form>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`form.media` loads `nestingdolls/sequence.js`, so people can add and remove
|
|
207
|
+
item rows without a reload. Kinda like in the Django admin inlines. Django
|
|
208
|
+
still names the controls, builds the rows, and does all the validation on
|
|
209
|
+
submitted values.
|
|
210
|
+
|
|
211
|
+
## Show a saved order again
|
|
212
|
+
|
|
213
|
+
Give `initial` the nested Python value you saved, not prefixed browser names:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
saved_order = {
|
|
217
|
+
'customer_id': 17,
|
|
218
|
+
'items': [
|
|
219
|
+
{'product_id': 42, 'quantity': 2},
|
|
220
|
+
{'product_id': 73, 'quantity': 1},
|
|
221
|
+
],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
form = CheckoutForm(initial={'order': saved_order})
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`order-items-0-product_id` is an HTTP submission name. It only exists after
|
|
228
|
+
Django has rendered and named real controls. `initial` wants ordinary Python
|
|
229
|
+
data, from before that whole naming circus starts.
|
|
230
|
+
|
|
231
|
+
## More ways to use the fields
|
|
232
|
+
|
|
233
|
+
The checkout example follows the full browser path. The same fields should also
|
|
234
|
+
provide value at other boundaries: an API, an import job, or a background
|
|
235
|
+
worker.
|
|
236
|
+
|
|
237
|
+
### Bind decoded application data
|
|
238
|
+
|
|
239
|
+
Got data from an API client or worker? Hand over the saved order directly.
|
|
240
|
+
Unlike `initial`, this is a submission, so Django validates it and puts it in
|
|
241
|
+
`cleaned_data` with everything else.
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
form = CheckoutForm({'order': saved_order})
|
|
245
|
+
|
|
246
|
+
assert form.is_valid()
|
|
247
|
+
assert form.cleaned_data['order'] == saved_order
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Validate an imported JSON, YAML, or CSV list
|
|
251
|
+
|
|
252
|
+
Decode the import to normal Python data first. Then let the child field do its
|
|
253
|
+
thing: convert and validate every value.
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
import csv
|
|
257
|
+
import json
|
|
258
|
+
|
|
259
|
+
import yaml
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class ProductImportForm(forms.Form):
|
|
263
|
+
product_ids = nestingdolls.ListField(forms.IntegerField(min_value=1))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
json_form = ProductImportForm(json.loads('{"product_ids": [42, 73]}'))
|
|
267
|
+
yaml_form = ProductImportForm(
|
|
268
|
+
yaml.safe_load('product_ids:\n - 42\n - 73')
|
|
269
|
+
)
|
|
270
|
+
# The list could come from a csv.DictReader row etc. too.
|
|
271
|
+
csv_form = ProductImportForm({'product_ids': next(csv.reader(['42,73']))})
|
|
272
|
+
|
|
273
|
+
for form in (json_form, yaml_form, csv_form):
|
|
274
|
+
assert form.is_valid()
|
|
275
|
+
assert form.cleaned_data['product_ids'] == [42, 73]
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Use `yaml.safe_load`, not `yaml.load`.
|
|
279
|
+
|
|
280
|
+
### Constrain a repeated field
|
|
281
|
+
|
|
282
|
+
Use `min_length` and `max_length` when a list needs a useful range, not just
|
|
283
|
+
any number of rows.
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
class TeamForm(forms.Form):
|
|
287
|
+
member_ids = nestingdolls.ListField(
|
|
288
|
+
forms.IntegerField(min_value=1),
|
|
289
|
+
min_length=1,
|
|
290
|
+
max_length=10,
|
|
291
|
+
)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
A submitted list of one through ten valid IDs cleans normally. More than ten
|
|
295
|
+
gets a validation error.
|
|
296
|
+
|
|
297
|
+
### Return a frozenset or another domain value
|
|
298
|
+
|
|
299
|
+
You don't have to settle for a mutable `list` or `dict` for your cleaned
|
|
300
|
+
value.
|
|
301
|
+
|
|
302
|
+
Use `FrozenSetField` when repeated values should be unique and stay immutable.
|
|
303
|
+
`TupleField`, `DataclassField`, and `NamedTupleField` make the other return
|
|
304
|
+
shapes in this example; see the [field reference](#the-fields-included) for
|
|
305
|
+
their parameters.
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
from dataclasses import dataclass
|
|
309
|
+
from typing import NamedTuple
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class Dimensions:
|
|
314
|
+
width: int
|
|
315
|
+
height: int
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class Limits(NamedTuple):
|
|
319
|
+
low: int
|
|
320
|
+
high: int
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class DimensionsForm(forms.Form):
|
|
324
|
+
width = forms.IntegerField(min_value=1)
|
|
325
|
+
height = forms.IntegerField(min_value=1)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class LimitsForm(forms.Form):
|
|
329
|
+
low = forms.IntegerField()
|
|
330
|
+
high = forms.IntegerField()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class PreferencesForm(forms.Form):
|
|
334
|
+
pinned_product_ids = nestingdolls.FrozenSetField(forms.IntegerField())
|
|
335
|
+
dimensions = nestingdolls.DataclassField(DimensionsForm, output=Dimensions)
|
|
336
|
+
limits = nestingdolls.NamedTupleField(LimitsForm, output=Limits)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
form = PreferencesForm(
|
|
340
|
+
{
|
|
341
|
+
'pinned_product_ids': [42, '73', 42],
|
|
342
|
+
'dimensions': {'width': '10', 'height': 20},
|
|
343
|
+
'limits': {'low': 1, 'high': '5'},
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
assert form.is_valid()
|
|
348
|
+
assert form.cleaned_data['pinned_product_ids'] == frozenset({42, 73})
|
|
349
|
+
assert form.cleaned_data['dimensions'] == Dimensions(width=10, height=20)
|
|
350
|
+
assert form.cleaned_data['limits'] == Limits(low=1, high=5)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Keep uploads with their row
|
|
354
|
+
|
|
355
|
+
When a repeated row has ordinary values and an upload, make that row a child
|
|
356
|
+
form. Then bind it the normal multipart way:
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
class AttachmentForm(forms.Form):
|
|
360
|
+
description = forms.CharField()
|
|
361
|
+
document = forms.FileField()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class EvidenceForm(forms.Form):
|
|
365
|
+
attachments = nestingdolls.ListField(
|
|
366
|
+
nestingdolls.DictField(AttachmentForm),
|
|
367
|
+
min_length=1,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
form = EvidenceForm(request.POST, request.FILES)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Render it with `enctype='multipart/form-data'`. `FileField` handles the
|
|
375
|
+
upload; the list field keeps every file beside its description. They arrived
|
|
376
|
+
together, so they can leave together.
|
|
377
|
+
|
|
378
|
+
## The fields included
|
|
379
|
+
|
|
380
|
+
As mentioned at the beginning, high level, the 2 basic building blocks are
|
|
381
|
+
[`DictField`](#dictfield) (for nested key/value shaped data) and
|
|
382
|
+
[`ListField`](#listfield) (for "up to N of this field" data) and everything
|
|
383
|
+
else is a variant of those.
|
|
384
|
+
|
|
385
|
+
### `DictField`
|
|
386
|
+
|
|
387
|
+
Renders and cleans a child `Form`, returning its cleaned values as a `dict`
|
|
388
|
+
by default.
|
|
389
|
+
|
|
390
|
+
Should you prefer, you can also use `FormField` or `Subform` or `MappingField`
|
|
391
|
+
(they're all aliases for the same thing).
|
|
392
|
+
|
|
393
|
+
| Parameter | What it does |
|
|
394
|
+
| --- | --- |
|
|
395
|
+
| `form_class` | The child Django `Form` class that supplies the named fields. |
|
|
396
|
+
| `output` | A callable that turns the cleaned child values into the returned value; defaults to `dict`. |
|
|
397
|
+
|
|
398
|
+
### `NamedTupleField`
|
|
399
|
+
|
|
400
|
+
A subclass of the `DictField` which returns a `NamedTuple` instance instead.
|
|
401
|
+
|
|
402
|
+
| Parameter | What it does |
|
|
403
|
+
| --- | --- |
|
|
404
|
+
| `form_class` | The child Django `Form` class that supplies the named fields. |
|
|
405
|
+
| `output` | The `NamedTuple` class used to build the returned value. |
|
|
406
|
+
|
|
407
|
+
### `DataclassField`
|
|
408
|
+
|
|
409
|
+
Like the `NamedTupleField` above, it's a subclass of the `DictField` but
|
|
410
|
+
it returns a `dataclass` instance. Shocking, I'm sure.
|
|
411
|
+
|
|
412
|
+
| Parameter | What it does |
|
|
413
|
+
| --- | --- |
|
|
414
|
+
| `form_class` | The child Django `Form` class that supplies the named fields. |
|
|
415
|
+
| `output` | The dataclass used to build the returned value. |
|
|
416
|
+
|
|
417
|
+
### `ListField`
|
|
418
|
+
|
|
419
|
+
Renders and cleans any number of one child field, returning the cleaned rows as
|
|
420
|
+
a list. Basically the approximate equivalent of a formset, but without the
|
|
421
|
+
ceremony of handling it separately.
|
|
422
|
+
|
|
423
|
+
Also available as `SequenceField`.
|
|
424
|
+
|
|
425
|
+
| Parameter | What it does |
|
|
426
|
+
| --- | --- |
|
|
427
|
+
| `child_field` | The Django field repeated for every row. |
|
|
428
|
+
| `min_length` | The fewest cleaned rows allowed. |
|
|
429
|
+
| `max_length` | The most cleaned rows allowed. |
|
|
430
|
+
| `absolute_max` | The hard cap on submitted rows, including rows Django must reject before cleaning. |
|
|
431
|
+
|
|
432
|
+
### `TupleField` (`FrozenSequenceField`)
|
|
433
|
+
|
|
434
|
+
Your standard `ListField`, but returns an immutable tuple.
|
|
435
|
+
|
|
436
|
+
| Parameter | What it does |
|
|
437
|
+
| --- | --- |
|
|
438
|
+
| `child_field` | The Django field repeated for every row. |
|
|
439
|
+
| `min_length` | The fewest cleaned rows allowed. |
|
|
440
|
+
| `max_length` | The most cleaned rows allowed. |
|
|
441
|
+
| `absolute_max` | The hard cap on submitted rows, including rows Django must reject before cleaning. |
|
|
442
|
+
|
|
443
|
+
### `SetField`
|
|
444
|
+
|
|
445
|
+
A `SequenceField` which returns a set and keeps each cleaned value once.
|
|
446
|
+
|
|
447
|
+
| Parameter | What it does |
|
|
448
|
+
| --- | --- |
|
|
449
|
+
| `child_field` | The Django field repeated for every row. |
|
|
450
|
+
| `min_length` | The fewest cleaned values allowed after duplicates are removed. |
|
|
451
|
+
| `max_length` | The most cleaned values allowed after duplicates are removed. |
|
|
452
|
+
| `absolute_max` | The hard cap on submitted rows, including rows Django must reject before cleaning. |
|
|
453
|
+
|
|
454
|
+
### `FrozenSetField`
|
|
455
|
+
|
|
456
|
+
It's the same as `SetField`, but giving back an immutable `frozenset` instead.
|
|
457
|
+
|
|
458
|
+
| Parameter | What it does |
|
|
459
|
+
| --- | --- |
|
|
460
|
+
| `child_field` | The Django field repeated for every row. |
|
|
461
|
+
| `min_length` | The fewest cleaned values allowed after duplicates are removed. |
|
|
462
|
+
| `max_length` | The most cleaned values allowed after duplicates are removed. |
|
|
463
|
+
| `absolute_max` | The hard cap on submitted rows, including rows Django must reject before cleaning. |
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
## Design notes
|
|
467
|
+
|
|
468
|
+
### The render patch
|
|
469
|
+
|
|
470
|
+
Django knows whether `form.as_p()` or `form.as_table()` is rendering a form,
|
|
471
|
+
but it does not tell the widget. The app installs a small wrapper around
|
|
472
|
+
`BaseForm.render`, `__str__`, and `__html__` so composite wrappers can choose
|
|
473
|
+
the matching layout. Custom form templates go straight through Django's usual
|
|
474
|
+
rendering path.
|
|
475
|
+
|
|
476
|
+
### On set behaviour...
|
|
477
|
+
|
|
478
|
+
`SetField` and `FrozenSetField` turn cleaned rows into a set before checking
|
|
479
|
+
the length. Two `42`s are one value, not two. They also compare membership
|
|
480
|
+
without caring about row order: the same set in a different order is still the
|
|
481
|
+
same set.
|
|
482
|
+
|
|
483
|
+
### JavaScript helpers for `SequenceField`
|
|
484
|
+
|
|
485
|
+
It emits bubbling
|
|
486
|
+
`nestingdolls:sequence-ready`, cancelable `nestingdolls:sequence-add` and
|
|
487
|
+
`nestingdolls:sequence-remove`, and after-the-fact
|
|
488
|
+
`nestingdolls:sequence-change` events. You should listen for those and make
|
|
489
|
+
something happen that that fits your page, maybe it's a toast or counter or
|
|
490
|
+
something? I dunno!
|
|
491
|
+
|
|
492
|
+
`preventDefault()` stops an add or removal.
|
|
493
|
+
|
|
494
|
+
If your page replaces sequence markup after it loads, dispatch
|
|
495
|
+
`new Event("nestingdolls:sequence-enhance")` on `document` after insertion.
|
|
496
|
+
The helpers rescan the document and enhance the new sequence widgets.
|
|
497
|
+
|