Skip to content

Add MultiWidget support: fix FieldGroup data handling and renderer CSS classes - #269

Open
Spawin wants to merge 4 commits into
jrief:releases/2.2from
Spawin:releases/2.2
Open

Add MultiWidget support: fix FieldGroup data handling and renderer CSS classes#269
Spawin wants to merge 4 commits into
jrief:releases/2.2from
Spawin:releases/2.2

Conversation

@Spawin

@Spawin Spawin commented May 29, 2026

Copy link
Copy Markdown

Summary

Django's MultiWidget renders sub-widgets as separate HTML elements with distinct name attributes (e.g. phone_numbers_0, phone_numbers_1, …). Two independent layers were broken for any form using MultiWidget:

  • Frontend: FieldGroup crashed on initialization and submitted wrong data
  • Backend: Form renderers did not apply framework CSS classes (e.g. form-control, uk-input) to MultiWidget sub-widgets

This is a follow-up to #148, a first attempt at fixing assertUniqueName for MultiWidget. This PR addresses the same root cause more completely, also fixing data submission, error mapping, CSS rendering, and adds unit tests.


Commit 1 — Frontend client/django-formset/DjangoFormset.ts

Root causes

  1. assertUniqueName() threw when a FieldGroup contained Input-based sub-widgets with different names — the original code required all elements to share one name.

  2. The constructor discarded Input elements when a Select was also present in the same group: this.fieldElements = [selectElement] overwrote the already-collected inputs. This meant the TextInput value was silently lost in any mixed widget (TextInput + Select).

  3. aggregateValues() submitted data under the wrong key — everything ended up under price_0 instead of separate price_0 / price_1 keys, breaking Django's MultiWidget.value_from_datadict().

  4. Server error messages were never displayedthis.name was set to price_0 while Django returns field errors keyed by price.

Changes

Constructor (~line 126) — stop Select from overwriting Input elements

if (this.fieldElements.length === 0) {
    this.fieldElements = [selectElement];
} else {
    this.fieldElements.push(selectElement);  // MultiWidget: append, do not overwrite
}

assertUniqueName() — allow differing names; derive base Django field name

No longer throws when sub-widgets carry different names. When multiple distinct non-checkbox/radio names are detected (seen.size > 1), strips the _N suffix to return the base name (phone_numbers, price), so server error mapping and setFieldValue lookups work correctly.

aggregateValue() — multi-element fallback for non-checkbox/radio fields

New else branch collects { name, value } pairs per sub-widget, serving as both the value capture and the detection signal used by aggregateValues().

aggregateValues() — expand MultiWidget into separate keys

Detects { name, value } object arrays and emits each sub-widget under its own key, matching Django's expected fieldname_N format:

Before: { "price_0": [{ name: "price_0", value: "150" }, …] }
After:  { "price_0": "150", "price_1": "USD" }

Known limitation: MultiWidgets whose sub-widgets are all ChoiceWidget-based silently drop every select after the first due to .at(0) in the constructor. This pre-existing edge case is out of scope.


Commit 2 — Backend renderer + test fixtures + unit tests

formset/renderers/default.py — fix CSS classes on MultiWidget sub-widgets

MultiWidget renders via django/forms/widgets/multiwidget.html, which iterates sub-widgets using {% include widget.template_name %}. Django's {% include %} bypasses the form renderer's render() method entirely, so _context_modifiers (which apply framework CSS classes) were never invoked for sub-widget templates.

A single addition to default.py fixes all frameworks at once:

def _amend_multiwidget(self, context):
    for subwidget in context['widget']['subwidgets']:
        template = subwidget['template_name']
        if template not in self._multiwidget_amendable_templates:
            continue
        modifier = self._context_modifiers.get(template)
        if callable(modifier):
            types.MethodType(modifier, self)({'widget': subwidget})
    return context

Because self is the concrete renderer instance, self._context_modifiers resolves to the framework-specific mapping at runtime — no changes to bootstrap.py, bulma.py, tailwind.py, or uikit.py.

Framework TextInput gets Select gets
Bootstrap form-control form-select
Bulma input
UIkit uk-input uk-select
Tailwind formset-text-input formset-select

testapp/forms/multiwidget.py — test fixture (no third-party dependency)

Two custom fields cover the two distinct failure scenarios:

  • PhoneField (3 × TextInput) — exercises the assertUniqueName crash and multi-input data aggregation.
  • PriceField (TextInput + Select, via PriceWidget) — explicitly documents and exercises the constructor overwrite: before the fix, the Select silently discarded the already-collected TextInput, making the amount value unrecoverable.

testapp/tests/test_multiwidget.py — 17 unit tests (all passing)

CSS rendering (11 tests): renders PhoneWidget and PriceWidget directly with each framework renderer and asserts the correct CSS class on each sub-widget element. Covers Bootstrap, Bulma, UIkit, Tailwind, and the default renderer.

Form validation (6 tests): submits data in the fieldname_N format that aggregateValues() now emits, and asserts valid data passes, compress() returns the expected value, and missing or invalid sub-widget data fails with the correct field error.

testapp/views.py

Registers MultiWidgetForm at the multiwidget endpoint.

@jrief

jrief commented May 29, 2026

Copy link
Copy Markdown
Owner

Instead of receiving a huge pull request, I would prefer a reproducable description of a problem you're trying to solve. This would give me the opportunity to look for a solution which fits into django-formset's mindset.
I kindly asked for this here: https://django-formset.fly.dev/contributing/

@Spawin

Spawin commented May 30, 2026

Copy link
Copy Markdown
Author

Instead of receiving a huge pull request, I would prefer a reproducable description of a problem you're trying to solve. This would give me the opportunity to look for a solution which fits into django-formset's mindset. I kindly asked for this here: https://django-formset.fly.dev/contributing/

Thank you for the feedback @jrief. This PR addresses bug #61, for which I had already opened PR #148 as a first attempt. Let me describe the problem properly.


Problem description

When using a Django form with a MultiWidget field (e.g. MultiValueField with multiple TextInput sub-widgets), two independent issues occur.

1. JavaScript crash on page load

FieldGroup.assertUniqueName() throws an uncaught exception because it requires all elements within a group to share the same name. Django's MultiWidget renders each sub-widget with a distinct name (phone_0, phone_1, …), which violates this assumption.

This only happens when the sub-widgets inherit from Input. Sub-widgets based on ChoiceWidget (i.e. <select>) do not trigger the crash because the constructor silently keeps only the first select element, discarding the rest.

2. Framework CSS classes not applied to sub-widgets

MultiWidget renders via django/forms/widgets/multiwidget.html using {% include %} for each sub-widget. This bypasses renderer.render() entirely, so _context_modifiers are never called for sub-widget templates. As a result, sub-widgets never receive their framework CSS classes (form-control, uk-input, etc.).


Minimal reproducible example

To reproduce both issues, I added a test form and registered it at /bootstrap/multiwidget. The form definition is here:
https://github.com/Spawin/django-formset/blob/12faf37d28f6c78d73b5b6188d2bc57cad220e30/testapp/forms/multiwidget.py

In short:

from django import forms
from formset.forms import Form

class PhoneWidget(forms.MultiWidget):
    def __init__(self, attrs=None):
        super().__init__(
            widgets=[forms.TextInput, forms.TextInput, forms.TextInput],
            attrs=attrs,
        )

    def decompress(self, value):
        return value.split('-') if value else ['', '', '']

class PhoneField(forms.MultiValueField):
    widget = PhoneWidget()

    def __init__(self, **kwargs):
        super().__init__(
            fields=(forms.CharField(), forms.CharField(), forms.CharField()),
            **kwargs,
        )

    def compress(self, data_list):
        return '-'.join(data_list)

class MultiWidgetForm(Form):
    phone = PhoneField()

Loading this form in any framework produces the JS crash. The sub-widget inputs also render without form-control or any other framework class.


Expected behavior

  • MultiWidget-based fields should render and function correctly within <django-formset>
  • Sub-widget inputs should receive the same CSS classes as regular standalone inputs
  • Form data should be submitted and validated correctly

I have a working implementation ready if you would like to review it once the design direction is confirmed. Happy to provide any additional information.

@jrief

jrief commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Sorry, didn't have time yet to have a look at this pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants