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

Conditional form fields in admin #2172

Open
alexgleason opened this issue Jan 29, 2016 · 12 comments
Open

Conditional form fields in admin #2172

alexgleason opened this issue Jan 29, 2016 · 12 comments

Comments

@alexgleason
Copy link
Contributor

We should allow certain fields in the page editor to appear/disappear depending on prior selections made.

For instance, let's say you are selecting the author of a blog post. You also have a team members model. You can ask:

Is the author a member of this website? (y/n)

If yes, the following field will be a page chooser panel for team member pages. If no, the following fields will allow you to directly input information about the author of the post.

(has this been requested before? Sorry if so, I couldn't find it)

I think the challenge would be in making an API that doesn't clutter up your real model fields with conditional form code. Perhaps we could have a ConditionalFieldPanel which takes in FieldPanels as arguments. But this should also work with StreamField so maybe we can't do that. Perhaps have a separate mechanism for this entirely.

@davecranwell
Copy link
Contributor

See also #381 where this was first touched on (although that was mainly about model logic). I think the very first conversation, around the same time but undocumented, was for some of the Form Builder fields to only appear/disappear based on your choices e.g the choice options would only appear if you added a choice field.

@thibaudcolas thibaudcolas changed the title Conditional forms in admin Conditional form fields in admin May 26, 2020
@thibaudcolas thibaudcolas added this to the some-day milestone May 26, 2020
@joegarlick
Copy link

I would LOVE this added! :) I keep wanting to use OR type logic for field inputs for better CMS admin UX for the front-end solutions I am designing.

@zerolab
Copy link
Contributor

zerolab commented Jul 1, 2021

fwiw, https://github.com/octavenz/wagtail-advanced-form-builder does this for the form builder (and end-user input)

@joegarlick
Copy link

Cool, our use case isn't for building forms on the front-end but for conditional logic for fields in the CMS admin for building more modular templates.

@hallpower
Copy link

Would love to see this added too. Three use cases:

  1. a follow-up field should be hidden/visible based on input into a prior field. This would allow for a wizard-style progressive disclosure functionality.
  2. content fields that depend on the input from another field (an example is the one asked in https://stackoverflow.com/questions/58711842/how-to-add-conditional-fields-programmatically-to-a-wagtail-page-type), basically a stepwise approach to narrowing a large choice set (in this case car model).
  3. ability to make inputs from one part of the editor process, choices in a later part. Example would be:
  • entering all the locations of a tour in a route input orderable using a geowidet in the first step
  • entering the hotels along the route and having a dynamic location chooser in a later stage which only lists locations entered as part of the route (in this case, hotel locations might be a subset of all locations on the route).

@ssyberg
Copy link

ssyberg commented Feb 25, 2022

👍🏼 would also love this

@gasman gasman removed this from the some-day milestone Mar 30, 2022
@develmaycare
Copy link

Plus one and a comment: I've done this (extensively) in Django by using Wagtail-inspired field "control" classes that specify options for toggle (boolean fields) and on select (choice fields). The control instance exports the necessary data-ui-toggle or data-ui-onselect as JSON, and tells the view to load some JavaScript that will parse and act upon this data.

Seems to me that FieldPanel could do exactly the same thing. Here are a couple of examples from my UI system:

# make EIN visible and required when a company is identified as an employer
controls = {
    'is_employer': ui.controls.BooleanControl(
        align="center",
        css_icon=True,
        toggle=ui.forms.Toggle("employer_identification_number", required=True, visible=True)
    ),
}
# make restock_fee, etc. visible when the return_policy is refund, refund-replace, or replace
controls = {
    'return_policy': ui.controls.CharControl(
        on_select=[
            ui.forms.OnSelect(
                [
                    "refund",
                    "refund-replace",
                    "replace",
                ],
                [
                    "restock_fee",
                    "return_policy_days",
                    "return_policy_statement",
                ],
                visible=True
            )
        ]
    ),
}

BooleanControl and CharControl (inspired by FieldPanel) know how to add data attributes to the the form field while Toggle and OnSelect tell the view what JavaScript files are needed to create the corresponding UI from the data. The model's clean() method is used to enforce client side logic.

wagtailuiplus provided similar functionality using class names and a hook to load the JavaScript. But as of mid 2021, the author has said "there are no plans to update Wagtail UI Plus to the latest version of Wagtail".

@lb-
Copy link
Member

lb- commented Jul 3, 2023

Adding this to the Stimulus project, I'm investigating some custom JS that's used in the admin for conditional field logic.

When this code is pulled out to a Stimulus controller, it may be practical to make this behaviour easy to reuse in other admin fields.

@Nigel2392
Copy link
Contributor

Plus one and a comment: I've done this (extensively) in Django by using Wagtail-inspired field "control" classes that specify options for toggle (boolean fields) and on select (choice fields). The control instance exports the necessary data-ui-toggle or data-ui-onselect as JSON, and tells the view to load some JavaScript that will parse and act upon this data.

Seems to me that FieldPanel could do exactly the same thing. Here are a couple of examples from my UI system:

# make EIN visible and required when a company is identified as an employer
controls = {
    'is_employer': ui.controls.BooleanControl(
        align="center",
        css_icon=True,
        toggle=ui.forms.Toggle("employer_identification_number", required=True, visible=True)
    ),
}
# make restock_fee, etc. visible when the return_policy is refund, refund-replace, or replace
controls = {
    'return_policy': ui.controls.CharControl(
        on_select=[
            ui.forms.OnSelect(
                [
                    "refund",
                    "refund-replace",
                    "replace",
                ],
                [
                    "restock_fee",
                    "return_policy_days",
                    "return_policy_statement",
                ],
                visible=True
            )
        ]
    ),
}

BooleanControl and CharControl (inspired by FieldPanel) know how to add data attributes to the the form field while Toggle and OnSelect tell the view what JavaScript files are needed to create the corresponding UI from the data. The model's clean() method is used to enforce client side logic.

wagtailuiplus provided similar functionality using class names and a hook to load the JavaScript. But as of mid 2021, the author has said "there are no plans to update Wagtail UI Plus to the latest version of Wagtail".

I do this by overriding the has_perm method on the user model by inheriting from a custom-made mixin.
You could then just use FieldPanel(...permission=ObjectExistsCheck(model=MyModel))


from django.utils.crypto import md5

"""

This is a utility for checking permissions on the Account user model class.
It is used by the ``has_perm`` method on the Account user model class.
It allows for wagtail panels to be arbitrarily controlled by permissions, 
and/or other objects.

"""

def make_hash(fragment_name, vary_on=None):
    """
        Simple utility function for generating a hash from different inputs.
        Can be used to generate a unique cache key for a fragment.
    """
    hasher = md5(usedforsecurity=False)
    if vary_on is not None:
        if not isinstance(vary_on, (list, tuple)):
            vary_on = [vary_on]
        for arg in vary_on:
            hasher.update(str(arg).encode())
            hasher.update(b":")
    return f"{fragment_name}.{hasher.hexdigest()}"


def is_iterable(x):
    "An implementation independent way of checking for iterables"
    try:
        iter(x)
    except TypeError:
        return False
    else:
        return True
    
class KeyNotImplementedError(NotImplementedError):
    pass

class ObjectPermissionCheck:
    """
    For checking permissions on a model class. This is used by the
    ``has_perm`` method on the Account user model class.
    This can be used to implement object-level permissions by overriding the
    ``check_perm_for_user`` classmethod.

    This allows for some pretty cool recursion with permissions.

    - Note for wagtail users
        Using this as ``permission`` in a panel; 
        will allow you to arbitrarily control if the panel is shown or not.
    """

    def object_key(self):
        """
        Returns a key that uniquely identifies this permission check.
        Makes sure permissions are only checked once per object instance.
        """
        raise KeyNotImplementedError("Subclasses must implement this.")
    
    def check_perm_for_user(self, user) -> bool:
        raise NotImplementedError("Subclasses must implement this.")
    
class ObjectExistsCheck(ObjectPermissionCheck):
    model = None

    def __init__(self, model=None, **filters):
        if model is not None:
            self.model = model
        self.filters = filters

    def object_key(self):
        model_name = ""
        if self.model is not None:
            model_name = self.model.__name__.lower()
        if not self.filters:
            return make_hash(f"check_{self.__class__.__name__.lower()}_{model_name}_exists")
        try:
            return make_hash(f"check_{self.__class__.__name__.lower()}_{model_name}_exists", vary_on=self.filters.values())
        except Exception:
            return make_hash(f"check_{self.__class__.__name__.lower()}_{model_name}_exists", vary_on=self.filters.keys())
        
    def check_perm_for_user(self, user) -> bool:
        if not self.filters:
            return self.model._default_manager.all().exists()
        return self.model._default_manager.filter(**self.filters).exists()

class ObjectListCheck(ObjectPermissionCheck):
    def __init__(self, *permissions):
        """
            Can be used for recursively checking permissions.

            Example:
                ObjectListCheck(
                    "app_label.permission_name",
                    # Don't know why you would do this, but you can.
                    ObjectListCheck(
                        "app_label.permission_name",
                        ObjectListCheck(
                            "app_label.permission_name",
                        )
                    ),
                    # Might actually come in handy.
                    ObjectExistsCheck(
                        model=SomeModel,
                        some_field=some_value,
                    ),
                )
        """
        self.permissions = permissions

    def object_key(self):
        return make_hash("check_permission_list", vary_on=self.permissions)

    def check_perm_for_user(self, user) -> bool:
        if not is_iterable(self.permissions):
            return user.has_perm(self.permissions)
        return user.has_perms(self.permissions)
    
    def __iter__(self):
        return iter(self.permissions)
    
    def __add__(self, other):
        if isinstance(other, ObjectListCheck):
            return ObjectListCheck(*self.permissions, *other.permissions)
        self.permissions.append(other)
        return self
    
    def __sub__(self, other):
        if isinstance(other, ObjectListCheck):
            return ObjectListCheck(*self.permissions, *other.permissions)
        self.permissions.remove(other)
        return self
    
class ObjectPermChecker:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._obj_perm_dict = {}

    def save(self, *args, **kwargs):
        res = super().save(*args, **kwargs)
        self._obj_perm_dict = {}
        return res

    def has_perm(self, perm: str | ObjectPermissionCheck, obj=None) -> bool:
        """
        Return True if the user has the specified permission. Query all
        available auth backends, but return immediately if any backend returns
        True. Thus, a user who has permission from a single auth backend is
        assumed to have permission in general. If an object is provided, check
        permissions for that object.
        """

        # Regular ol' permission.
        if isinstance(perm, str):
            return super().has_perm(perm, obj=obj)
        
        # Check if the object itself can check the user's permissions.
        # Be careful with recusion!
        try:
            if not isinstance(perm, ObjectPermissionCheck):
                if isinstance(perm, (list, tuple)):
                    perm = ObjectListCheck(*perm)
                elif issubclass(perm, ObjectPermissionCheck):
                    perm = perm()
                else:
                    raise AttributeError("Unknown permission type.")

            # Check if it is in the cache
            try:
                return self._obj_perm_dict[perm.object_key()]
            except (KeyNotImplementedError, AttributeError, KeyError):
                pass

            # It is not in the cache; check the permission
            # and/or if it is implemented
            result = perm.check_perm_for_user(self)
            try:
                # Cache the result if it is implemented
                self._obj_perm_dict[perm.object_key()] = result
            except (KeyNotImplementedError, AttributeError):
                pass
            return result
        except AttributeError:
            # We don't know this type of permission.
            # Pass it on.
            return super().has_perm(perm, obj=obj)

@ZzBombardierzZ
Copy link

+1

@lb-
Copy link
Member

lb- commented Jan 24, 2024

For those watching this issue. There is a bare minimum start to this in the form of a Stimulus controller.

It only supports dynamic disabling but hopefully it will be a building block for more.

#11202

Any feedback welcome, there is still a way to go even after this is merged before there would be an officially documented Python driven API for this kind of form/widgets though.

@Stormheg
Copy link
Member

+1

@ZzBombardierzZ please express your interest by adding a thumbs up to the original post. A very short comment like yours is a tad noisy. Thanks for your understanding 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: No status
Development

No branches or pull requests