Skip to content

Latest commit

 

History

History
423 lines (311 loc) · 13.2 KB

File metadata and controls

423 lines (311 loc) · 13.2 KB

rg.forms - Reactive Django Forms

1 Problem Statement

Django forms lack built-in support for common reactive behaviors:

  1. Conditional field visibility - show/hide fields based on other field values
  2. Dynamic requirements - make fields required based on conditions
  3. Computed fields - calculate values from other fields
  4. Cascading choices - filter dropdown options based on other selections
  5. Cross-field validation - validate fields in relation to each other

Currently, developers must:

  • Write JavaScript for frontend reactivity
  • Duplicate logic in Python for backend validation
  • Keep both in sync manually

1.1 Our Approach

Backend is the single source of truth. All reactive rules are defined in Python on the form class. The frontend (Datastar) reflects these rules - it makes the backend rules visible to the user in real-time.

JavaScript is always enabled. We don’t maintain fallback code for disabled JavaScript.

Simplicity over flexibility. Datastar is our frontend framework. We can add adapters for HTMX+Alpine later if needed, but we don’t over-engineer now.

2 Architecture

@startuml
skinparam componentStyle rectangle

package "rg.forms" {
  [ReactiveForm] --> [ReactiveFields]
  [ReactiveFields] --> [Validation]
  [ReactiveForm] --> [TemplateRenderer]
  [TemplateRenderer] --> [DatastarAttrs]
}

package "Django" {
  [Form] <|-- [ReactiveForm]
  [Field] <|-- [ReactiveFields]
}

package "Frontend" {
  [DatastarAttrs] --> [Datastar]
  [Datastar] --> [Browser]
}

note right of ReactiveForm
  Single source of truth
  All rules defined here
end note

note right of DatastarAttrs
  Auto-generated from
  Python field attributes
end note
@enduml

3 API Design

3.1 ReactiveForm

Extends django.forms.Form. All reactive behavior is defined via field attributes.

from rg.forms import ReactiveForm, ReactiveCharField, ReactiveChoiceField

class OrderForm(ReactiveForm):
    order_type = ReactiveChoiceField(
        choices=[('standard', 'Standard'), ('urgent', 'Urgent')],
    )

    # visible_when: Datastar expression, also evaluated on backend
    priority = ReactiveChoiceField(
        choices=[('normal', 'Normal'), ('high', 'High')],
        visible_when="$order_type == 'urgent'",
        required=False,
    )

    # required_when: Field becomes required when condition is true
    notes = ReactiveCharField(
        required_when="$order_type == 'urgent'",
    )

    # computed: Value calculated from other fields
    total = ReactiveDecimalField(
        computed="$quantity * $unit_price",
    )

3.2 Field Attributes

AttributeTypeDescription
visible_whenstrDatastar expression. Field hidden when false.
required_whenstrDatastar expression. Field required when true.
computedstrDatastar expression. Field value is calculated.
choices_urlstrURL to fetch choices from (for cascading).
depends_onlistField names that trigger choices refresh.

3.3 Expressions

Expressions use Datastar syntax with $fieldname for signal references:

visible_when="$order_type == 'urgent'"
visible_when="$order_type == 'urgent' || $order_type == 'express'"
visible_when="$quantity > 10"
computed="$quantity * $unit_price"

The same expressions are:

  1. Rendered as Datastar attributes (data-show, data-bind)
  2. Evaluated on backend during validation (converted to Python)

3.4 Backend Validation

ReactiveForm automatically:

  1. Skips hidden fields - if visible_when evaluates to false, field is not validated
  2. Enforces dynamic requirements - if required_when evaluates to true, field must have value
  3. Recomputes computed fields - ignores submitted value, recalculates from source fields
def view(request):
    form = OrderForm(request.POST)
    if form.is_valid():
        # Hidden fields excluded from cleaned_data
        # Computed fields have server-calculated values
        # Dynamic requirements enforced
        process(form.cleaned_data)

3.5 Template Tags

{% load reactive_forms %}

{# Use single quotes - JSON uses double quotes internally #}
<form data-signals='{% reactive_signals form %}'>
  <div {% reactive_wrapper_attrs form.priority %}>
    <input {% reactive_input_attrs form.priority %}>
  </div>
  {% required_indicator form.notes %}
</form>

3.6 View Integration

Works with both FBV and CBV. No special view classes needed.

def order_view(request):
    if request.method == 'POST':
        form = OrderForm(request.POST)
        if form.is_valid():
            save_order(form.cleaned_data)
            return redirect('success')
    else:
        form = OrderForm()
    return render(request, 'order.html', {'form': form})

4 Implementation Plan

4.1 Phase 1: Core Foundation [DONE]

  • [X] Package structure (PEP 420 namespace)
  • [X] ReactiveForm base class
  • [X] Reactive field classes
  • [X] Field attributes (visible_when, required_when, computed, depends_on)
  • [X] Signal generation (get_signals_json)
  • [X] Template tags
  • [X] Testsite with examples

4.2 Phase 2: Expression Evaluation [DONE]

Backend evaluation of expressions.

  • [X] Expression parser (safe subset)
  • [X] Python evaluator
  • [X] Skip validation for hidden fields (visible_when=false)
  • [X] Enforce required_when
  • [X] Recompute computed fields
  • [X] Tests

4.3 Phase 2.5: Conditional Attributes [DONE]

Additional reactive field attributes.

  • [X] disabled_when - conditionally disable fields
  • [X] read_only_when - conditionally make fields read-only
  • [X] help_text_when - dynamic help text based on conditions
  • [X] placeholder_when - dynamic placeholder text
  • [X] min_when, max_when - dynamic min/max bounds
  • [X] Template updates for data-attr:disabled, data-attr:readonly
  • [X] Example in testsite

4.4 Phase 3: Cascading Choices [DONE]

Server-side form re-rendering for dependent fields (declarative approach).

  • [X] choices_from - callable or queryset for dynamic choices
  • [X] depends_on - field names that trigger re-population
  • [X] value_field, label_field, label_template - choice rendering options
  • [X] empty_choice, empty_choice_no_parent - placeholder options
  • [X] Datastar data-on:change to POST form for re-rendering (partial update)
  • [X] View pattern: detect “repopulate” vs “submit” requests
  • [X] Validate that selected value is valid for current parent
  • [X] Example: category -> products cascading form

4.5 Phase 4: Field Groups [DONE]

Organize fields into logical sections.

  • [X] FieldGroup class with label, description, css_class
  • [X] visible_when on entire groups
  • [X] Meta.field_groups configuration
  • [X] render_field_group template tag
  • [X] Example in testsite

4.6 Phase 5: Form Rendering [DONE]

  • [X] {% render_reactive_form %} tag
  • [X] {% render_reactive_field %} tag
  • [X] {% render_field_group %} tag
  • [X] Bulma skin (default)
  • [ ] Bootstrap 5 skin
  • [X] Error display

4.7 Phase 6: Documentation

  • [ ] README with examples
  • [ ] API documentation
  • [ ] Comparison with SPA approaches

5 Design Decisions

5.1 1. Datastar-first

We use Datastar. If other frameworks needed later, we can add adapters.

5.2 2. Backend is Source of Truth

All rules defined in Python. Frontend reflects rules, doesn’t define them.

5.3 3. No Progressive Enhancement

JavaScript required. No fallback for disabled JS.

5.4 4. Expression Safety

Safe subset only: field references, literals, basic operators. No function calls.

5.5 5. View-agnostic

Works with FBV and CBV. No special mixins required.

6 File Structure

src/rg/forms/
├── __init__.py
├── apps.py
├── forms.py             # ReactiveForm
├── fields.py            # Reactive field classes
├── expressions.py       # Expression parser & evaluator [Phase 2]
├── templatetags/
│   └── reactive_forms.py
└── templates/
    └── rg_forms/
        ├── field.html
        └── form.html

7 Risks and Mitigation

7.1 High Risk

7.1.1 Datastar Dependency

Datastar is v1.0-RC (not stable 1.0 yet) with a small community compared to HTMX (~25K stars) or Alpine (~29K stars). If Datastar API changes significantly or project becomes unmaintained, rg.forms is tightly coupled.

Mitigation:

  • Abstract expression syntax where possible
  • Document migration path to HTMX + Alpine if needed
  • Monitor Datastar project health and community growth
  • Expression evaluator is already backend-only (not Datastar-dependent)

7.1.2 API Stability / Missing Flows

Field attributes have grown significantly: visible_when, required_when, computed, disabled_when, read_only_when, help_text_when, placeholder_when, min_when, max_when, depends_on, choices_from

More will likely be needed as real-world usage reveals gaps:

  • error_message_when - dynamic error messages
  • css_class_when - conditional styling
  • choices_when - conditional choice filtering
  • FieldGroup: nested groups, collapsible sections, ordering

Mitigation:

  • Semantic versioning with clear deprecation policy
  • Deprecation warnings before breaking changes
  • Collect real-world usage feedback before stabilizing API
  • Consider attrs_when dict pattern instead of individual *_when attributes

7.1.3 Expression Language Limitations

Current parser supports only basic operations. Users will hit limits:

Missing FeatureExample
Functionslen($items), $date.year, contains($text, 'x')
Ternary$a ? $b : $c
Array access$items[0], $items.length
String opsstartsWith, endsWith, includes
Nested fields$address.city

Mitigation:

  • Document supported expression subset clearly
  • Provide escape hatch: custom clean_* methods for complex logic
  • Prioritize most-requested features for expression parser
  • Consider allowing Python lambdas for complex computed fields

7.2 Medium Risk

7.2.1 Missing Django Integrations

Common Django patterns not yet supported:

  • ReactiveModelForm - most common use case in real apps
  • Formsets / InlineFormsets - editing multiple related objects
  • FileField with upload progress and preview
  • Integration with crispy-forms, django-widget-tweaks

Mitigation:

  • Prioritize ReactiveModelForm for next phase
  • Document workarounds for formsets
  • Provide custom widget documentation

7.2.2 No Client-side Validation Messages

All validation is server-side. Users expect instant feedback without round-trip for simple rules like “required”, “min length”, “email format”.

Mitigation:

  • HTML5 validation attributes are already rendered (required, min, max, minlength, maxlength)
  • Document that HTML5 provides instant feedback for common cases
  • Consider optional client-side validation layer for complex rules

7.2.3 Expression Errors are Silent

Typos in expressions ($ordertpye instead of $order_type) fail silently, defaulting to =True=/visible. This makes debugging difficult.

def _evaluate_expression(self, expression: str) -> bool | None:
    try:
        return evaluate_expression(expression, self._get_form_data())
    except ExpressionError:
        return None  # Silently defaults to True/visible

Mitigation:

  • Add DEBUG mode that raises on expression errors
  • Add form validation at definition time (check field names exist)
  • Log expression errors with field context
  • Consider check_expressions() method for testing

7.2.4 No IDE Support for Expressions

Expression strings like "$order_type = ‘urgent’”= have no autocomplete, type checking, or refactoring support. Easy to make typos.

Mitigation:

  • Document expression syntax clearly with examples
  • Consider future TypedDict or dataclass approach
  • Provide check_expressions() for CI/testing
  • IDE plugin is out of scope but could be community contribution

7.3 Lower Risk

7.3.1 Single CSS Framework

Only Bulma templates implemented. Bootstrap 5 and other frameworks not yet supported.

Mitigation:

  • Bootstrap 5 skin planned for Phase 5
  • Template override mechanism already works
  • Document custom template creation

7.3.2 Performance at Scale

  • Server round-trip for every cascading dropdown change
  • No built-in debouncing for rapid input
  • Large forms with many expressions have parsing overhead

Mitigation:

  • Datastar handles debouncing via data-on:change__debounce_500ms
  • Expression parsing is fast (simple tokenizer + recursive descent)
  • Document performance best practices
  • Consider caching parsed ASTs if profiling shows need

7.3.3 No Async Validation

Cannot do “check username availability” or similar async checks without custom code.

Mitigation:

  • Document pattern for async validation with Datastar
  • Out of scope for core library - keep it simple
  • Custom clean_* methods can call external services

8 Future Considerations

8.1 Potential Phase 7: Advanced Features

Based on risk analysis, these features may be needed:

  • [ ] ReactiveModelForm - model-backed reactive forms
  • [ ] Expression expansion: ternary, string functions, nested fields
  • [ ] check_expressions() method for testing/CI
  • [ ] DEBUG mode for expression errors
  • [ ] Async validation pattern documentation
  • [ ] HTMX adapter (if Datastar risk materializes)