Skip to content

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


Resources