I have a use case where I need to add dynamic form fields to a WagtailModelAdminForm
. With standard django I would normally just create a custom subclass and add the fields in the __init__
method of the form. In Wagtail, because the forms are built up with the edit_handlers, this becomes a nightmare to deal with.
I have the following dynamic form:
class ProductForm(WagtailAdminModelForm):
class Meta:
model = get_product_model()
exclude = ['attributes', 'state', 'variant_of']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance:
self.inject_attribute_fields()
def inject_attribute_fields(self):
for k, attr in self.instance.attributes.items():
field_klass = None
field_data = attr.get("input")
field_args = {
'label': field_data['name'],
'help_text': field_data['help_text'],
'required': field_data['is_required'],
'initial': attr['value'],
}
if 'choices' in field_data:
field_args['choices'] = (
(choice["id"], choice["value"])
for choice in field_data['choices']
)
if field_data['is_multi_choice']:
field_klass = forms.MultipleChoiceField
else:
field_klass = forms.ChoiceField
else:
typ = field_data['attr_type']
if typ == 'text':
field_klass = forms.CharField
elif typ == 'textarea':
field_klass = forms.CharField
field_args['widget'] = forms.Textarea
elif typ == 'bool':
field_klass = forms.BooleanField
elif typ == 'int':
field_klass = forms.IntegerField
elif typ == 'decimal':
field_klass = forms.DecimalField
elif typ == 'date':
field_klass = forms.DateField
field_args['widget'] = AdminDateInput
elif typ == 'time':
field_klass = forms.TimeField
field_args['widget'] = AdminTimeInput
elif typ == 'datetime':
field_klass = forms.DateTimeField
field_args['widget'] = AdminDateTimeInput
if field_klass is None:
raise AttributeError('Cannot create widgets for invalid field types.')
# Create the custom key
self.fields[f"attributes__{k}"] = field_klass(**field_args)
Next I customized the ModelAdmin EditView
(attributes are not present in the create view):
class EditProductView(EditView):
def get_edit_handler(self):
summary_panels = [
FieldPanel('title'),
FieldPanel('description'),
FieldPanel('body'),
]
# NOTE: Product attributes are dynamic, so we generate them
attributes_panel = get_product_attributes_panel(self.instance)
variants_panel = []
if self.instance.is_variant:
variants_panel.append(
InlinePanel(
'stockrecords',
classname="collapsed",
heading="Variants & Prices"
)
)
else:
variants_panel.append(ProductVariantsPanel())
return TabbedInterface([
ObjectList(summary_panels, heading='Summary'),
# This panel creates dynamic panels related to the dynamic form fields,
# but raises an error saying that the "fields are missing".
# Understandable because it's not present on the original model
# ObjectList(attributes_panel, heading='Attributes'),
ObjectList(variants_panel, heading='Variants'),
ObjectList(promote_panels, heading='Promote'),
ObjectList(settings_panels, heading='Settings'),
], base_form_class=ProductForm).bind_to_model(self.model_admin.model)
Here is the get_product_attributes_panel()
function for reference:
def get_product_attributes_panel(product) -> list:
panels = []
for key, attr in product.attributes.items():
widget = None
field_name = "attributes__" + key
attr_type = attr['input'].get('attr_type')
if attr_type == 'date':
widget = AdminDateInput()
elif attr_type == 'datetime':
widget = AdminDateTimeInput()
else:
if attr_type is None and 'choices' in attr['input']:
if attr['input']['is_multi_choice']:
widget = forms.SelectMultiple
else:
widget = forms.Select
else:
widget = forms.TextInput()
if widget:
panels.append(FieldPanel(field_name, widget=widget))
else:
panels.append(FieldPanel(field_name))
return panels
So the problem is...
A) Adding the ProductForm in the way I did above (by using it as the base_form_class in TabbedInterface) almost works; It adds the fields to the form; BUT I have no control over the rendering.
B) If I uncomment the line ObjectList(attributes_panel, heading='Attributes'),
(to get nice rendering of the fields), then I get an error for my dynamic fields, saying that they are missing.
This is a very important requirement in the project I'm working on.
A temporary workaround is to create a custom panel to render the dynamic fields directly in the html template; But then I lose the Django Form validation, which is also an important requirement for this.
Is there any way to add dynamic fields the the WagtailModelAdminForm, that preserves the modeladmin features such as formsets, permissions etc.
I ended up creating a separate AttributeForm
for the attributes.
A custom Panel then looks for this new form instance as an attribute of the primary form. As an instance on the primary form, I can "clean" this internal form when the primary form clean()
is called and, raise any errors that I need to on both forms.
I then customized the EditView.post()
method to make sure that I add the instance of AttributesForm
to our primary model form.
It's a bit of a workaround, but works well enough for now. I wish there was an easier way, but it doesn't look like it right now.