0
# Forms and UI
1
2
Form classes and UI components for tree manipulation in Django applications. These forms provide user-friendly interfaces for moving nodes, creating hierarchical structures, and managing tree data with proper validation and error handling.
3
4
## Capabilities
5
6
### MoveNodeForm Class
7
8
Main form class for moving nodes within tree structures, with dropdown tree selection and position controls.
9
10
```python { .api }
11
class MoveNodeForm(forms.ModelForm):
12
"""
13
Form for moving nodes within tree structure.
14
15
Provides dropdown tree selection and position options
16
based on whether the tree uses node_order_by.
17
"""
18
19
_position = forms.ChoiceField(
20
label="Position",
21
help_text="Choose where to move the node"
22
)
23
24
_ref_node_id = forms.ChoiceField(
25
label="Reference Node",
26
help_text="Choose the reference node for the move"
27
)
28
29
def save(self, commit=True):
30
"""
31
Save form and perform node move operation.
32
33
Parameters:
34
commit (bool): Whether to commit the move immediately
35
36
Returns:
37
Node: The moved node instance
38
39
Raises:
40
InvalidPosition: If position is invalid
41
InvalidMoveToDescendant: If moving to descendant
42
"""
43
44
def mk_dropdown_tree(self, model, for_node=None):
45
"""
46
Create hierarchical dropdown choices for node selection.
47
48
Parameters:
49
model (Model): Tree model class
50
for_node (Node, optional): Node being moved (excluded from choices)
51
52
Returns:
53
list: List of (value, label) tuples for dropdown
54
"""
55
56
def add_subtree(self, for_node, node, options):
57
"""
58
Recursively build tree options with indentation.
59
60
Parameters:
61
for_node (Node): Node being moved (to exclude descendants)
62
node (Node): Current node being processed
63
options (list): List to append options to
64
"""
65
66
def is_loop_safe(self, for_node, possible_parent):
67
"""
68
Check if move would create circular reference.
69
70
Parameters:
71
for_node (Node): Node being moved
72
possible_parent (Node): Potential new parent
73
74
Returns:
75
bool: True if move is safe (no circular reference)
76
"""
77
78
def mk_indent(self, level):
79
"""
80
Create indentation markup for tree display.
81
82
Parameters:
83
level (int): Indentation level (0 for root)
84
85
Returns:
86
str: HTML markup for indentation
87
"""
88
```
89
90
### Form Factory Function
91
92
Factory function for creating customized MoveNodeForm subclasses.
93
94
```python { .api }
95
def movenodeform_factory(model, form=MoveNodeForm, fields=None, exclude=None,
96
formfield_callback=None, widgets=None):
97
"""
98
Create MoveNodeForm subclass for specific model.
99
100
Parameters:
101
model (Model): Tree model class
102
form (Form): Base form class (default: MoveNodeForm)
103
fields (list, optional): Fields to include in form
104
exclude (list, optional): Fields to exclude from form
105
formfield_callback (callable, optional): Custom field generation
106
widgets (dict, optional): Custom widget overrides
107
108
Returns:
109
type: MoveNodeForm subclass configured for the model
110
"""
111
```
112
113
## Usage Examples
114
115
### Basic Form Setup
116
117
```python
118
# forms.py
119
from django import forms
120
from treebeard.forms import movenodeform_factory
121
from .models import Category
122
123
# Create form class for Category model
124
CategoryMoveForm = movenodeform_factory(Category)
125
126
# Or create with custom fields
127
CategoryMoveForm = movenodeform_factory(
128
Category,
129
fields=['name', 'description', 'active']
130
)
131
```
132
133
### Custom Form Implementation
134
135
```python
136
from django import forms
137
from treebeard.forms import MoveNodeForm
138
from .models import Category
139
140
class CategoryMoveForm(MoveNodeForm):
141
"""Custom move form with additional validation."""
142
143
class Meta:
144
model = Category
145
fields = ['name', 'description', 'active']
146
widgets = {
147
'description': forms.Textarea(attrs={'rows': 3}),
148
}
149
150
def __init__(self, *args, **kwargs):
151
super().__init__(*args, **kwargs)
152
153
# Customize position choices
154
if self.instance.pk:
155
# Filter available positions based on business logic
156
self.fields['_position'].choices = self.get_custom_positions()
157
158
def get_custom_positions(self):
159
"""Get position choices based on business rules."""
160
positions = []
161
162
if self.instance.node_order_by:
163
positions.extend([
164
('sorted-child', 'Child of (sorted)'),
165
('sorted-sibling', 'Sibling of (sorted)'),
166
])
167
else:
168
positions.extend([
169
('first-child', 'First child of'),
170
('last-child', 'Last child of'),
171
('left', 'Before'),
172
('right', 'After'),
173
])
174
175
return positions
176
177
def clean(self):
178
"""Additional validation for move operations."""
179
cleaned_data = super().clean()
180
181
position = cleaned_data.get('_position')
182
ref_node_id = cleaned_data.get('_ref_node_id')
183
184
if position and ref_node_id:
185
try:
186
ref_node = Category.objects.get(pk=ref_node_id)
187
188
# Business rule: Can't move to different root tree
189
if self.instance.get_root() != ref_node.get_root():
190
raise forms.ValidationError(
191
"Cannot move node to different root tree"
192
)
193
194
# Business rule: Active nodes can't be children of inactive nodes
195
if (position in ['first-child', 'last-child', 'sorted-child']
196
and self.instance.active and not ref_node.active):
197
raise forms.ValidationError(
198
"Active categories cannot be children of inactive categories"
199
)
200
201
except Category.DoesNotExist:
202
raise forms.ValidationError("Invalid reference node")
203
204
return cleaned_data
205
```
206
207
### View Integration
208
209
```python
210
# views.py
211
from django.shortcuts import render, get_object_or_404, redirect
212
from django.contrib import messages
213
from django.contrib.auth.decorators import login_required
214
from .models import Category
215
from .forms import CategoryMoveForm
216
217
@login_required
218
def move_category(request, category_id):
219
"""View for moving categories."""
220
category = get_object_or_404(Category, pk=category_id)
221
222
if request.method == 'POST':
223
form = CategoryMoveForm(request.POST, instance=category)
224
if form.is_valid():
225
try:
226
form.save()
227
messages.success(
228
request,
229
f'Successfully moved "{category.name}"'
230
)
231
return redirect('category_list')
232
except Exception as e:
233
messages.error(request, f'Move failed: {str(e)}')
234
else:
235
messages.error(request, 'Please correct the errors below')
236
else:
237
form = CategoryMoveForm(instance=category)
238
239
return render(request, 'categories/move.html', {
240
'form': form,
241
'category': category
242
})
243
244
def category_tree_json(request):
245
"""API endpoint for tree data (for AJAX forms)."""
246
categories = Category.get_annotated_list()
247
248
tree_data = []
249
for item in categories:
250
tree_data.append({
251
'id': item['node'].pk,
252
'name': item['node'].name,
253
'level': item['level'],
254
'open': item.get('open', False),
255
'close': item.get('close', False)
256
})
257
258
return JsonResponse({'tree': tree_data})
259
```
260
261
### Template Usage
262
263
```html
264
<!-- templates/categories/move.html -->
265
<form method="post">
266
{% csrf_token %}
267
268
<div class="form-group">
269
<label>Category to Move:</label>
270
<div class="form-control-static">
271
{{ category.name }}
272
<small class="text-muted">
273
(Current position: Level {{ category.get_depth }})
274
</small>
275
</div>
276
</div>
277
278
<!-- Regular model fields -->
279
{% for field in form %}
280
{% if not field.name.startswith('_') %}
281
<div class="form-group">
282
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
283
{{ field }}
284
{% if field.help_text %}
285
<small class="form-text text-muted">{{ field.help_text }}</small>
286
{% endif %}
287
{% if field.errors %}
288
<div class="invalid-feedback d-block">
289
{% for error in field.errors %}
290
{{ error }}
291
{% endfor %}
292
</div>
293
{% endif %}
294
</div>
295
{% endif %}
296
{% endfor %}
297
298
<!-- Move-specific fields -->
299
<fieldset class="border p-3 mb-3">
300
<legend class="w-auto px-2">Move Options</legend>
301
302
<div class="form-group">
303
<label for="{{ form._ref_node_id.id_for_label }}">
304
{{ form._ref_node_id.label }}
305
</label>
306
{{ form._ref_node_id }}
307
{% if form._ref_node_id.help_text %}
308
<small class="form-text text-muted">{{ form._ref_node_id.help_text }}</small>
309
{% endif %}
310
</div>
311
312
<div class="form-group">
313
<label for="{{ form._position.id_for_label }}">
314
{{ form._position.label }}
315
</label>
316
{{ form._position }}
317
{% if form._position.help_text %}
318
<small class="form-text text-muted">{{ form._position.help_text }}</small>
319
{% endif %}
320
</div>
321
</fieldset>
322
323
<div class="form-group">
324
<button type="submit" class="btn btn-primary">Move Category</button>
325
<a href="{% url 'category_list' %}" class="btn btn-secondary">Cancel</a>
326
</div>
327
</form>
328
329
<script>
330
// Optional: Dynamic position options based on reference node
331
document.getElementById('id__ref_node_id').addEventListener('change', function() {
332
const refNodeId = this.value;
333
const positionField = document.getElementById('id__position');
334
335
// Fetch available positions for selected reference node
336
if (refNodeId) {
337
fetch(`/api/categories/${refNodeId}/positions/`)
338
.then(response => response.json())
339
.then(data => {
340
positionField.innerHTML = '';
341
data.positions.forEach(pos => {
342
const option = document.createElement('option');
343
option.value = pos.value;
344
option.textContent = pos.label;
345
positionField.appendChild(option);
346
});
347
});
348
}
349
});
350
</script>
351
```
352
353
## Advanced Form Features
354
355
### AJAX Tree Selection
356
357
Create dynamic tree selection with AJAX loading:
358
359
```python
360
from django.http import JsonResponse
361
from django.views.decorators.http import require_http_methods
362
363
@require_http_methods(["GET"])
364
def get_tree_nodes(request):
365
"""API endpoint for dynamic tree loading."""
366
parent_id = request.GET.get('parent_id')
367
search = request.GET.get('search', '')
368
369
if parent_id:
370
try:
371
parent = Category.objects.get(pk=parent_id)
372
nodes = parent.get_children()
373
except Category.DoesNotExist:
374
nodes = Category.objects.none()
375
else:
376
nodes = Category.get_root_nodes()
377
378
if search:
379
nodes = nodes.filter(name__icontains=search)
380
381
data = []
382
for node in nodes:
383
data.append({
384
'id': node.pk,
385
'name': node.name,
386
'has_children': node.get_children_count() > 0,
387
'level': node.get_depth()
388
})
389
390
return JsonResponse({'nodes': data})
391
392
class AjaxCategoryMoveForm(MoveNodeForm):
393
"""Move form with AJAX tree selection."""
394
395
class Meta:
396
model = Category
397
fields = ['name', 'description']
398
399
def __init__(self, *args, **kwargs):
400
super().__init__(*args, **kwargs)
401
402
# Use AJAX widget for node selection
403
self.fields['_ref_node_id'].widget = forms.Select(
404
attrs={
405
'class': 'ajax-tree-select',
406
'data-ajax-url': '/api/categories/tree-nodes/'
407
}
408
)
409
```
410
411
### Inline Tree Forms
412
413
Create inline forms for tree editing:
414
415
```python
416
from django.forms import inlineformset_factory
417
418
# Create formset for editing tree structure
419
CategoryFormSet = inlineformset_factory(
420
Category, # Parent model
421
Category, # Child model (self-reference)
422
form=CategoryMoveForm,
423
fk_name='parent', # For AL_Node
424
extra=1,
425
can_delete=True
426
)
427
428
def edit_category_tree(request, category_id):
429
"""Edit category and its children."""
430
category = get_object_or_404(Category, pk=category_id)
431
432
if request.method == 'POST':
433
formset = CategoryFormSet(request.POST, instance=category)
434
if formset.is_valid():
435
formset.save()
436
messages.success(request, 'Tree updated successfully')
437
return redirect('edit_category_tree', category_id=category.pk)
438
else:
439
formset = CategoryFormSet(instance=category)
440
441
return render(request, 'categories/edit_tree.html', {
442
'category': category,
443
'formset': formset
444
})
445
```
446
447
### Validation Helpers
448
449
Common validation patterns for tree forms:
450
451
```python
452
class TreeValidationMixin:
453
"""Mixin with common tree validation methods."""
454
455
def validate_max_depth(self, max_depth=5):
456
"""Validate that move doesn't exceed maximum depth."""
457
position = self.cleaned_data.get('_position')
458
ref_node_id = self.cleaned_data.get('_ref_node_id')
459
460
if position and ref_node_id and 'child' in position:
461
ref_node = self.Meta.model.objects.get(pk=ref_node_id)
462
new_depth = ref_node.get_depth() + 1
463
464
if new_depth > max_depth:
465
raise forms.ValidationError(
466
f'Maximum tree depth ({max_depth}) would be exceeded'
467
)
468
469
def validate_business_rules(self):
470
"""Validate business-specific rules."""
471
# Example: Categories with products can't be moved
472
if hasattr(self.instance, 'products') and self.instance.products.exists():
473
raise forms.ValidationError(
474
'Categories containing products cannot be moved'
475
)
476
477
def clean(self):
478
cleaned_data = super().clean()
479
480
self.validate_max_depth()
481
self.validate_business_rules()
482
483
return cleaned_data
484
485
class CategoryMoveForm(TreeValidationMixin, MoveNodeForm):
486
"""Category move form with validation."""
487
488
class Meta:
489
model = Category
490
fields = ['name', 'description']
491
```
492
493
## Widget Customization
494
495
### Custom Tree Widget
496
497
```python
498
from django import forms
499
from django.utils.safestring import mark_safe
500
501
class TreeSelectWidget(forms.Select):
502
"""Custom widget for tree node selection."""
503
504
def __init__(self, tree_model, *args, **kwargs):
505
self.tree_model = tree_model
506
super().__init__(*args, **kwargs)
507
508
def render(self, name, value, attrs=None, renderer=None):
509
"""Render tree selection widget."""
510
html = ['<select name="{}" id="id_{}">'.format(name, name)]
511
512
if not value:
513
html.append('<option value="">---------</option>')
514
515
# Build tree options
516
for node in self.tree_model.get_annotated_list():
517
indent = ' ' * (node['level'] * 4)
518
selected = 'selected' if str(node['node'].pk) == str(value) else ''
519
520
html.append(
521
'<option value="{}" {}>{}{}</option>'.format(
522
node['node'].pk,
523
selected,
524
indent,
525
node['node'].name
526
)
527
)
528
529
html.append('</select>')
530
531
return mark_safe(''.join(html))
532
533
# Usage in form
534
class CategoryMoveForm(MoveNodeForm):
535
class Meta:
536
model = Category
537
fields = ['name']
538
widgets = {
539
'_ref_node_id': TreeSelectWidget(Category)
540
}
541
```
542
543
## Performance Considerations
544
545
For large trees, consider:
546
547
1. **Pagination**: Limit dropdown options
548
2. **AJAX Loading**: Load tree nodes on demand
549
3. **Caching**: Cache tree structure for read-heavy forms
550
4. **Lazy Loading**: Load subtrees as needed
551
552
```python
553
class OptimizedCategoryMoveForm(MoveNodeForm):
554
"""Optimized form for large trees."""
555
556
def mk_dropdown_tree(self, model, for_node=None):
557
"""Optimized dropdown with depth limits."""
558
options = [('', '---------')]
559
560
# Limit to reasonable depth for UI
561
max_depth = 4
562
nodes = model.objects.filter(depth__lte=max_depth)
563
564
# Use annotated list for efficiency
565
for item in model.get_annotated_list_qs(nodes):
566
if for_node and self.is_loop_safe(for_node, item['node']):
567
continue
568
569
indent = self.mk_indent(item['level'])
570
label = f"{indent}{item['node'].name}"
571
options.append((item['node'].pk, label))
572
573
return options
574
```