Form Component¶
Version: 0.6.0
Last Updated: January 3, 2026
Location: src/templates/forms/ & src/*/forms.py
Forms handle user input for creating and updating tickets, projects, users, and other resources. Arctyk forms integrate Bootstrap 5 styling, TinyMCE for rich text editing, and inline field validation.
Overview¶
Forms in Arctyk support:
- Django form rendering with Bootstrap 5 styling
- Rich text editing with TinyMCE for description/comment fields
- Inline validation with client-side feedback
- CSRF protection
- File uploads for attachments
- Multiple widget types (text, textarea, select, multiselect, checkbox)
- Custom error displays
Form Classes¶
TicketForm¶
from tickets.forms import TicketForm
class TicketCreateView(LoginRequiredMixin, CreateView):
model = Ticket
form_class = TicketForm
template_name = 'tickets/ticket_form.html'
def form_valid(self, form):
form.instance.created_by = self.request.user
return super().form_valid(form)
Field Types¶
| Field | Type | Widget | Notes |
|---|---|---|---|
title | CharField | TextInput | Required, max 255 chars |
description | TextField | TinyMCE | Rich text, supports formatting |
priority | ChoiceField | Select | CRITICAL, HIGH, MEDIUM, LOW |
status | ChoiceField | Select | OPEN, IN_PROGRESS, RESOLVED, CLOSED |
assigned_to | ModelChoiceField | Select | User selection |
due_date | DateTimeField | DateTimeInput | Optional deadline |
tags | CharField | Text | Comma-separated tagging |
Usage¶
Template Rendering¶
<form method="post" novalidate>
{% csrf_token %}
<div class="form-group mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label">
Title
</label>
{{ form.title }}
{% if form.title.errors %}
<div class="invalid-feedback d-block">
{{ form.title.errors.0 }}
</div>
{% endif %}
</div>
<div class="form-group mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">
Description
</label>
{{ form.description }}
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
Custom Form Tags¶
{% load form_tags %}
{% form_group form.title %}
{% form_group form.description with tinymce=True %}
{% form_group form.priority %}
{% form_group form.assigned_to %}
TinyMCE Integration¶
Configuration¶
# settings.py
TINYMCE_DEFAULT_CONFIG = {
'height': 400,
'width': '100%',
'plugins': 'link image code',
'toolbar': 'undo redo | bold italic | link image | code',
'menubar': False,
}
Template Usage¶
<textarea name="description" id="id_description" class="tinymce"></textarea>
<script src="{% static 'tinymce/tinymce.min.js' %}"></script>
<script>
tinymce.init({
selector: '.tinymce',
plugins: 'link image code',
toolbar: 'undo redo | bold italic | link image | code'
});
</script>
Validation¶
Server-Side Validation¶
from django import forms
class TicketForm(forms.ModelForm):
class Meta:
model = Ticket
fields = ['title', 'description', 'priority']
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get('title')
description = cleaned_data.get('description')
# Custom validation
if title and len(title) < 5:
self.add_error('title', 'Title must be at least 5 characters')
if not description:
self.add_error('description', 'Description is required')
return cleaned_data
Client-Side Validation¶
<form method="post" novalidate>
{% csrf_token %}
<div class="form-group">
<input
type="text"
name="title"
required
minlength="5"
class="form-control"
/>
<div class="invalid-feedback">
Please provide a valid title (minimum 5 characters)
</div>
</div>
<button type="submit">Submit</button>
</form>
<script>
// Bootstrap form validation
(function () {
"use strict";
window.addEventListener("load", function () {
const forms = document.querySelectorAll(".needs-validation");
Array.prototype.slice.call(forms).forEach(function (form) {
form.addEventListener(
"submit",
function (event) {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add("was-validated");
},
false
);
});
});
})();
</script>
Inline Field Editing¶
AJAX Form Submission¶
document.querySelectorAll("[data-editable]").forEach((element) => {
element.addEventListener("click", function (e) {
e.preventDefault();
const field = this.dataset.field;
const ticketId = this.dataset.ticket;
// Show edit form
showEditModal(field, ticketId);
});
});
function saveField(field, value) {
fetch(`/tickets/${ticketId}/edit-field/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken"),
},
body: JSON.stringify({ field, value }),
})
.then((response) => response.json())
.then((data) => {
if (data.success) {
location.reload();
}
});
}
View Handler¶
@require_http_methods(["POST"])
@login_required
def edit_field(request, pk):
ticket = get_object_or_404(Ticket, pk=pk)
data = json.loads(request.body)
field = data.get('field')
value = data.get('value')
# Validate user can edit
if ticket.assigned_to != request.user:
return JsonResponse({'error': 'Permission denied'}, status=403)
# Update field
setattr(ticket, field, value)
ticket.save(update_fields=[field])
return JsonResponse({'success': True})
Accessibility¶
ARIA Labels¶
<div class="form-group">
<label for="id_priority" class="form-label">
Priority <span class="text-danger">*</span>
</label>
<select name="priority"
id="id_priority"
class="form-control"
aria-label="Ticket priority"
aria-required="true">
<option value="">Select priority...</option>
<option value="low">Low</option>
<option value="high">High</option>
</select>
<small class="form-text text-muted" id="priority-help">
Choose the priority level for this ticket
</small>
</div>
Error Announcements¶
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show" role="alert"
aria-live="assertive" aria-atomic="true">
{{ form.non_field_errors.0 }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
Testing¶
Form Validation Tests¶
from django.test import TestCase
from tickets.forms import TicketForm
class TicketFormTest(TestCase):
def test_form_valid(self):
form = TicketForm(data={
'title': 'Test Ticket',
'description': 'A test ticket description',
'priority': 'high'
})
self.assertTrue(form.is_valid())
def test_form_missing_title(self):
form = TicketForm(data={
'description': 'No title provided',
'priority': 'high'
})
self.assertFalse(form.is_valid())
self.assertIn('title', form.errors)
def test_form_title_min_length(self):
form = TicketForm(data={
'title': 'Hi', # Too short
'description': 'Description',
'priority': 'high'
})
self.assertFalse(form.is_valid())
View Tests¶
class TicketCreateViewTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
password='testpass'
)
def test_get_form(self):
self.client.login(username='testuser', password='testpass')
response = self.client.get(reverse('tickets:ticket_create'))
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.context['form'], TicketForm)
def test_post_valid_form(self):
self.client.login(username='testuser', password='testpass')
response = self.client.post(reverse('tickets:ticket_create'), {
'title': 'New Ticket',
'description': 'Description',
'priority': 'high'
})
self.assertEqual(Ticket.objects.count(), 1)
Best Practices¶
✅ Always use {% csrf_token %} in POST forms
✅ Use form.non_field_errors for general validation messages
✅ Provide clear labels and help text
✅ Validate on both client and server
✅ Use appropriate input types (email, date, etc.)
✅ Test form validation thoroughly
✅ Make required fields visually obvious
❌ Don't rely on client-side validation alone
❌ Don't hardcode error messages
❌ Don't skip accessibility attributes