Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTMX example for Class Based View, and more generic #10

Open
nerdoc opened this issue Nov 21, 2023 · 5 comments
Open

HTMX example for Class Based View, and more generic #10

nerdoc opened this issue Nov 21, 2023 · 5 comments

Comments

@nerdoc
Copy link

nerdoc commented Nov 21, 2023

You provide one example with HTMX, where a model field is dependant on a make field and changes it's queryset accoding to the selected make. This is simple and comprehensive.
But in real life, there are more complicated structures. e.g., depending on the selection of a field, many other fields may change (or be included or not) in the form.
While the lambda functions work and can mirror the needed behaviour of each field, the widget-tweaks' render_field templatetag can not easily fetch one form field using HTMX, it must get all of the fields.

As more interdependencies may occur in dynamic forms, it would be generally a good behaviour to get the whole form with each change and reload the form - this does not costs much and simplifies the whole workflow - in theory.

In practice, I can't wrap my head around this, I sat here for hours trying to solve it, no chance.

class UserType(IntegerChoices):
    PERSON = 1
    COMPANY = 2

class UserCreateForm(DynamicFormMixin, UserCreationForm):
    # here we add the hx- attributes directly in the widget.
    user_type = forms.ChoiceField(
        choices=UserType.choices,
        initial=UserType.PERSON,
        widget=forms.RadioSelect(
            attrs={
                "hx-trigger": "change",
                "hx-get": ".",
                "hx-target": "#signup_form",
                "hx-select": "#signup_form",
                "hx-swap": "outerHTML",
                "hx-params": "*",
            },
        ),
    )

While the hx-attrs get attached correctly and "work", the View is always executed without the passed GET param (e.g. ?user_type=2)
And so, the Radioselect always stays the same.

I know, this is more a question, and not a bug. Or a docs request, to add documentation for HTMX forms.
Maybe you could provide a little example on how to use HTMX for more complicated forms...? Thank you very much in advance.
BTW, I use crispy-forms too (as it's the only way of keeping code maintainable in bigger projects).

@nerdoc
Copy link
Author

nerdoc commented Nov 22, 2023

I experimented a bit more with it, and am asking myself:

  • why should HTMX perform a GET request after a field change. You have to do extra work to get the data to the server, and if not, the form resets itself again, any data inserted before is lost.
    When you use a POST request, it works. The data is put to the server, validation is done correctly (which can be bad at this early state too)
  • why do you use 2 different (function based) views, and a second URL endpoint? HTMX is capable of using one endpoint (like your unpoly example) too. You can just use hx-target and hx-select attributes to pick out the correct parts of the same endpoint (e.g. using POST)

But this is only the tip of the iceberg.

In your simple example, this is easy to achieve: 2 URL endpoints, 2 simple views with 3 lines. But in reality, you can't write these views in a bigger application. You need access control constraints, validation, etc. Here CBV are much better on the long term (sorry, no flame war intended).

My form e.g. has one user_type field (2 choices), and depending on that, there are different other fields. This gets complicated fast. And you can't just create 27 "small" HTMX views to cover every case. This IMHO is easier done within one view and a logic within that.

@nerdoc
Copy link
Author

nerdoc commented Nov 26, 2023

As I use CBV, I wrote an example, which works generically with CBV, crispy forms and HTMX.

First, you need a mixin that uses GET data to prepopulate your form (This is just a separate class, to be reused elsewhery in my code too. It could be incorporated into the next class below, too):

class PrepopulateFormMixin(FormView):
    def get_initial(self):
        """Returns the initial data for the form."""
        initial = super().get_initial()
        initial.update(self.request.GET.dict())
        return initial

Then, I wrote another mixin that creates the context parameter you used I inherited from the first one. It first gets the initial values into the context, and then updates them with the current GET dict.

class DynamicFormViewMixin(PrepopulateFormMixin):
    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["context"] = self.get_initial()
        kwargs["context"].update(self.request.GET.dict())
        return kwargs

So you can use the initial = foo class attribute as well within your CBV, it will be used.
You don't need two endpoints (neither in urls,py, nor as two classes, for the updating. Just use crispy forms to render your form with {% crispy form %}, and update your trigger field's attributes either using MyForm.__init___():

def __init(self, *args, **kwargs):
    self.fields["trigger_field"].widget.attrs.update({
            "hx-trigger": "change", # not strictly necessary
            "hx-get": ".",
            "hx-target": "#div_id_dependent_field",
            "hx-select": "#div_id_dependent_field",
    })

This works perfectly. You could include these mixins in django-forms-dynamic if you want, and add some docs, and many people (like me) get happier ;-)

Maybe the trigger field htmx action could be added in a descriptive way too, I'm working on that...

@nerdoc nerdoc changed the title HTMX example for whole form, not only one field HTMX example for Class Based View, and more generic Nov 26, 2023
@nerdoc
Copy link
Author

nerdoc commented Nov 26, 2023

The code above reduces that view to

class MakeAndModelView(DynamicFormViewMixin, forms.Form): # or other generic view
    ...
    initial = {"make": "audi"}
    def __init(self,**kwargs):
        self.fields["make"].widget.attrs.update(
            {
                "hx-trigger": "change",
                "hx-get": ".",
                "hx-target": "#div_id_model",
                "hx-select": "#div_id_model",
            }
        )

If you add a parameter like "dependency" to DynamicField which marks the field the DynamicField is dependent on, your business logic could add this widget attr automatically.
This may be a bit more tricky when not using crispy forms, as the div_id_* may be called differently.

What it doesn't solve, is when a trigger field should update more than one other fields. e.g. when the "make" field, when "Audi" is selected, triggers

  • the model field updates it's queryset to only Audo models
  • another field named "quattro" should only be visible (included) when the make is "Audi".

This won't work, as HTMX only hx-selects and hx-targets one field. But, this won't work with your function based view neither. It could be solved using HTMX oob functionality.

@fordmustang5l
Copy link

I would like to respond with an example that might help, but I only code function based views. Would you still want a writeup?

@nerdoc
Copy link
Author

nerdoc commented Nov 30, 2023

No... in a bigger project I'm trying to get often reusable parts, and here are CBVs for me the best way (no flame war intended).

I am using this meanwhile (shortened):

class DynamicHtmxFormMixin(DynamicFormMixin):
    """
    Mixin class for creating dynamic forms with HTMX.
    [...]
    """

    class Meta:
        trigger_fields: list[str] = []
        update_url: str = "."
        trigger = "change"

    def __init__(self, form_id, *args, **kwargs):
        self.form_id = form_id
        super().__init__(*args, **kwargs)

        if not hasattr(self.Meta, "trigger_fields"):
            raise AttributeError(
                f"{self.__class__.__name__}.Meta has no 'trigger_fields' attribute."
            )

        trigger = self.Meta.trigger if hasattr(self.Meta, "trigger") else "changed"
        include_list = ",".join(
            [
                f"[name={field}]"
                for field in self.fields
                if not self.fields[field].widget.is_hidden
            ]
        )
        self.context["form_attrs"] = f"hx-include={include_list}"

        for field_name in self.Meta.trigger_fields:
            field = self.fields.get(field_name)
            if field:
                field.widget.attrs.update(
                    {
                        "hx-trigger": trigger,
                        "hx-get": self.get_update_url(),
                        "hx-target": f"#{self.form_id}",
                        "hx-select": f"#{self.form_id}",
                        # "hx-push-url": "true",
                        "hx-swap": "outerHTML",
                    }
                )
    def get_update_url(self):
        if hasattr(self.Meta, "update_url"):
            return self.Meta.update_url
        else:
            return "."
    def fields_required(self, fields: str | list[str], msg: str = None) -> None:
        """Helper method used for conditionally marking fields as required.
        Can be called in your form's clean() method.
        [...]
        Credits go to
        https://www.fusionbox.com/blog/detail/creating-conditionally-required-fields-in-django-forms/577/
        """
        if type(fields) is str:
            fields = [fields]
        for field in fields:
            if not self.cleaned_data.get(field, ""):
                msg = (
                    forms.ValidationError(msg)
                    if msg
                    else forms.ValidationError(_("This field is required."))
                )
                self.add_error(field, msg)

This works reasonably well. It just needs to get context as usual and form_id from the view.

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

No branches or pull requests

2 participants