Source code for wildewidgets.widgets.forms
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Final, cast
from django.urls import URLPattern, path, reverse
from ..forms import ToggleableManyToManyFieldForm
from .base import Block
from .structure import CardWidget, HorizontalLayoutBlock
if TYPE_CHECKING:
from django.db.models import Model
from django.forms import Form
[docs]class LabelBlock(Block):
"""
A ``<label>``
Args:
text: the text for the label
Keyword Args:
bold: if ``True``, make the label text be bold
color: the bootstrap/tabler color to assign to the label
for_input: the CSS id of the input this describes
"""
tag: str = "label"
#: If ``True``, make the label text be bold
bold: bool = True
#: The CSS id of the input this describes
for_input: str | None = None
def __init__(
self,
text: str,
for_input: str | None = None,
bold: bool | None = None,
color: str = "secondary", # noqa: ARG002
**kwargs: Any,
) -> None:
self.bold = bold if bold is not None else self.bold
self.for_input = for_input if for_input is not None else self.for_input
super().__init__(text, **kwargs)
if self.for_input:
self._attributes["for"] = self.for_input
if self.bold:
self.add_class("fw-bold")
[docs]class InputBlock(Block):
"""
A block for rendering a simple ``<input>`` element.
Example:
.. code-block:: python
from wildewidgets import InputBlock
# A simple text input
block = InputBlock(input_type='text', input_name='my-input', value='Hello')
Keyword Args:
input_type: the value of the ``type`` attribute
input_name: the value of the ``name`` attribute
value: the value of the ``value`` attribute
"""
empty: bool = True
tag: str = "input"
input_type: str | None = None
input_name: str | None = None
value: str | None = None
def __init__(
self,
input_type: str | None = None,
input_name: str | None = None,
value: str | None = None,
**kwargs: Any,
) -> None:
self.input_type = input_type or self.input_type
self.input_name = input_name or self.input_name
self.value = value or self.value
super().__init__(**kwargs)
if self.input_type is not None:
self._attributes["type"] = self.input_type
if self.input_name is not None:
self._attributes["name"] = self.input_name
if self.value is not None:
self._attributes["value"] = self.value
[docs]class BaseCheckboxInputBlock(InputBlock):
"""
A block for rendering a bare ``<input type="checkbox">`` element.
Example:
.. code-block:: python
from wildewidgets import BaseCheckboxInputBlock
block_unchecked = BaseCheckboxInputBlock(input_name='my-checkbox', value=1)
block_checked = BaseCheckboxInputBlock(
input_name='my-checkbox',
value=1,
checked=True
)
Keyword Args:
checked: if ``True``, render the checkbox as checked
"""
input_type: str = "checkbox"
def __init__(
self,
checked: bool = False,
**kwargs: Any,
) -> None:
self.checked = checked
super().__init__(**kwargs)
if not self.input_name:
msg = "input_name"
raise self.RequiredAttrOrKwarg(msg)
if not self.value:
msg = "value"
raise self.RequiredAttrOrKwarg(msg)
if self.checked:
self._attributes["checked"] = ""
[docs]class CheckboxInputBlock(Block):
"""
A block for rendering a ``<input type="checkbox">`` element with a label as a
`Boostrap 5 check <https://getbootstrap.com/docs/5.2/forms/checks-radios/#checks>`_.
Example:
.. code-block:: python
from wildewidgets import CheckboxInputBlock
block = CheckboxInputBlock(
label_text='My Checkbox',
name='my-checkbox',
value=1
)
Keyword Args:
label_text: the text to use for the label
bold: if ``True``, make the label text be bold
input_name: the value of the ``name`` attribute
value: the value of the ``value`` attribute
checked: if ``True``, render the checkbox as checked
"""
#: The value of the ``name`` attribute on the checkbox
input_name: str | None = None
#: The value of the ``value`` attribute on the checkbox
value: str | None = None
#: The text to use for the label for the checkbox
label_text: str | None = None
#: if ``True``, make the label text be bold
bold: bool = True
def __init__(
self,
label_text: str | None = None,
bold: bool | None = None,
input_name: str | None = None,
value: str | None = None,
checked: bool = False,
**kwargs: Any,
) -> None:
self.label_text = label_text or self.label_text
self.input_name = input_name or self.input_name
self.bold = bold if bold is not None else self.bold
self.value = value or self.value
if not self.label_text:
msg = "label_text"
raise self.RequiredAttrOrKwarg(msg)
if not self.input_name:
msg = "input_name"
raise self.RequiredAttrOrKwarg(msg)
if not self.value:
msg = "value"
raise self.RequiredAttrOrKwarg(msg)
self.input_css_id = kwargs.pop(
"css_id", f"checkbox-{self.input_name}-{self.value}"
)
self.checked = checked
super().__init__(**kwargs)
self.add_class("form-check")
self.add_block(
BaseCheckboxInputBlock(
input_name=self.input_name,
value=self.value,
css_id=self.input_css_id,
checked=self.checked,
css_class="form-check-input",
)
)
self.add_block(
LabelBlock(
text=self.label_text,
bold=self.bold,
for_input=self.input_css_id,
css_class="form-check-label",
)
)
[docs]class ToggleSwitchInputBlock(CheckboxInputBlock):
"""
A block for rendering a ``<input type="checkbox">`` element with a label as
a `Bootstrap 5 switch <https://getbootstrap.com/docs/5.0/forms/checks-radios/#switches>`_.
Example:
.. code-block:: python
from wildewidgets import ToggleSwitchInputBlock
block = ToggleSwitchInputBlock(
label_text='My Checkbox',
name='my-checkbox',
value=1
)
"""
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.add_class("form-switch")
[docs]class HiddenInputBlock(InputBlock):
"""
A block for rendering a ``<input type="hidden">``.
Example:
.. code-block:: python
from wildewidgets import HiddenInputBlock
block = HiddenInputBlock(name='my-checkbox', value=1)
"""
input_type: str = "hidden"
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
if not self.input_name:
msg = "input_name"
raise self.RequiredAttrOrKwarg(msg)
if not self.value:
msg = "value"
raise self.RequiredAttrOrKwarg(msg)
[docs]class CrispyFormWidget(Block):
"""
A widget that displays a Crispy Form widget. This is specifically a Crispy
Form widget because it uses the ``{% crispy %}`` template tag to render the
form.
Note:
If the form is present in the template context already, (because your
view inserted it for you) you don't need to supply the ``form`` keyword
argument -- ``CrispyFormWidget`` will pick it up automatically.
Example:
.. code-block:: python
from wildewidgets import CrispyFormWidget
from myapp.forms import MyCrispyForm
block = CrispyFormWidget(form=MyCrispyForm())
Args:
*blocks: Any blocks to add to this widget. These will be rendered in the
order they are provided.
Keyword Args:
form: A crispy form to display. If none is specified, it will be assumed
that ``form`` is specified elsewhere in the context.
**kwargs: Any additional keyword arguments to pass to the parent class.
"""
#: The Django template to use to render this widget.
template_name: str = "wildewidgets/crispy_form_widget.html"
#: The name of this block.
block: str = "wildewidgets-crispy-form-widget"
def __init__(self, *blocks: Any, form: Form | None = None, **kwargs: Any) -> None:
super().__init__(*blocks, **kwargs)
self.form = form
[docs] def get_context_data(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
kwargs = super().get_context_data(*args, **kwargs)
if self.form:
kwargs["form"] = self.form
return kwargs
[docs]class ToggleableManyToManyFieldBlock(CardWidget):
"""
A block that allows you to manage a many-to-many relationship between a
model and a related model as a list of toggleable items.
This widget displays all available related objects as toggle switches,
with currently selected items checked. It provides interactive features
including:
- A toggle to hide/show unselected items
- A search box to filter items by name
This is most appropriate for relationships between a model and a
lookup table (e.g., categories, tags, classifiers, etc.) where the
related model has a reasonable number of instances.
Important:
This block is designed to be used with a model that has a many-to-many
relationship with another model. The `field_name` must be set to the name
of the many-to-many field on the model instance. The block will render a
form that allows the user to select or deselect related objects.
Also, this block requires that your model has a ``verbose_name_plural``
method defined in its `Meta` class.
We strongly recommend that you use the
:py:class:`wildewidgets.ViewSetMixin` mixin in your model to provide the
required attributes and methods for this block to work correctly.
Example:
.. code-block:: python
from wildewidgets import ToggleableManyToManyFieldBlock
from myapp.models import Article
article = Article.objects.get(pk=1) # Has many-to-many with 'tags'
tags_widget = ToggleableManyToManyFieldBlock(
instance=article,
field_name='tags'
)
Args:
instance: The model instance that has the many-to-many relationship
Keyword Args:
field_name: Name of the many-to-many field on the model instance
form_class: Custom form class to use (must extend
``ToggleableManyToManyFieldForm)
form_action: URL for form submission (if not provided, will be auto-generated)
"""
#: The name of this block.
name: str = "toggle-form-block"
#: The JavaScript to run to hide unselected items and filter the list
#: of items.
#: The ``{target}`` placeholder will be replaced with the CSS id of the form
#: that this block will render.
#: The ``{filter_id}`` placeholder will be replaced with the CSS id of the
#: filter input.
#: The ``{show_unselected_id}`` placeholder will be replaced with the CSS id
#: of the "Hide unselected" toggle switch.
#: The JavaScript will hide all unselected items on page load, and will
#: toggle the visibility of unselected items when the "Hide unselected" toggle
#: is clicked. It will also filter the list of items based on the text in
#: the filter input.
SCRIPT: Final[str] = """
document.querySelectorAll("{target} input.form-check-input").forEach(input => {{
if (!input.checked) {{
input.parentElement.classList.add('d-none');
}};
}});
var show_input = document.getElementById("{show_unselected_id}");
show_input.onchange = function(e) {{
document.querySelectorAll("{target} input.form-check-input").forEach(input => {{
if (show_input.checked && !input.checked) {{
input.parentElement.classList.add('d-none');
}} else {{
input.parentElement.classList.remove('d-none');
}};
}});
}};
var filter_input = document.getElementById("{filter_id}");
filter_input.onkeyup = function(e) {{
var filter = filter_input.value.toLowerCase();
show_input.checked = false
document.querySelectorAll("{target} label").forEach(label => {{
var test_string = label.innerText.toLowerCase();
if (test_string.includes(filter)) {{
label.parentElement.classList.remove('d-none');
}} else {{
label.parentElement.classList.add('d-none');
}}
}});
}};
"""
#: The model this widget will be used with. This is only used by our
#: :py:meth:`as_v`
model: type[Model] | None = None
#: The name of the related field on our model instance for which we want to
#: build this widget
field_name: str | None = None
#: The form class to instantiate to handle our multiple select
form_class: type[Form] = ToggleableManyToManyFieldForm
#: The URL to which to POST this form
form_action: str | None = None
#: The path prefix to use to root view.
url_prefix: str = ""
#: The URL namespace to use for our view name. This should be set to
#: the ``app_name`` set in the ``urls.py`` where this viewset's patterns
#: will be added to ``urlpatterns``.
url_namespace: str | None = None
def __init__(
self,
instance: Model,
field_name: str | None = None,
form_class: type[Form] | None = None,
form_action: str | None = None,
**kwargs: Any,
) -> None:
self.instance = instance
self.model = instance.__class__
if hasattr(self.model, "model_verbose_name_plural"):
self.verbose_name_plural = cast(
"Model", self.model
).model_verbose_name_plural()
else:
self.verbose_name_plural = self.model._meta.verbose_name_plural
assert self.verbose_name_plural, ( # noqa: S101
f"{self.model.__class__.__name__} msust have a "
"verbose_name_plural in its Meta class"
)
if not self.verbose_name_plural[0].isupper():
self.verbose_name_plural = self.verbose_name_plural.capitalize()
self.field_name = field_name or self.field_name
if self.field_name is None:
msg = "field_name must be provided"
raise ValueError(msg)
self.field = self.instance._meta.get_field(self.field_name)
self.related_model = self.field.related_model
self.form_class = form_class or self.form_class
self.form_action = form_action if form_action is not None else self.form_action
if not self.form_action:
url_name = self.get_url_name()
if self.url_namespace:
url_name = f"{self.url_namespace}:{url_name}"
self.form_action = reverse(url_name, kwargs={"pk": self.instance.id})
kwargs["script"] = self.SCRIPT.format(
target=f"#{self.form_id}",
filter_id=self.filter_id,
show_unselected_id=self.show_all_switch_id,
)
super().__init__(self, **kwargs)
self.set_header(self.get_header)
self.set_widget(
CrispyFormWidget(
form=self.get_form(
self.instance, self.field_name, cast("str", self.form_action)
),
css_id=self.form_id,
)
)
@property
def form_id(self) -> str:
"""
Generate a unique CSS ID for the form element.
This property creates a standardized ID by combining the model's object name
with the field name. This ID is used for DOM selection in JavaScript and
for linking form elements.
Returns:
str: A string in the format of "modelname_fieldname"
Raises:
AssertionError: If the model is not defined
"""
assert self.model is not None, "self.model must be defined" # noqa: S101
return f"{self.model._meta.object_name.lower()}_{self.field_name}" # type: ignore[union-attr]
@property
def filter_id(self) -> str:
"""
Generate a unique CSS ID for the filter input element.
This property appends "_filter" to the form_id to create a unique
identifier for the search input field used to filter the list of items.
Returns:
str: A string in the format of "modelname_fieldname_filter"
"""
return f"{self.form_id}_filter"
@property
def show_all_switch_id(self) -> str:
"""
Generate a unique CSS ID for the "show all" toggle switch.
This property appends "_show_all" to the form_id to create a unique
identifier for the toggle switch that controls the visibility of
unselected items.
Returns:
str: A string in the format of "modelname_fieldname_show_all"
"""
return f"{self.form_id}_show_all"
[docs] def get_form(self, instance: Model, field_name: str, form_action: str) -> Form:
"""
Create and return a form for managing many-to-many relationships.
This method instantiates a form of the class specified by the form_class
attribute, binding it to the specified instance, field name, and form action.
The form will be used to manage the many-to-many relationship between the
instance and related model objects.
Args:
instance: The model instance to which the form will be bound
field_name: The name of the many-to-many field on the model
form_action: The URL to which the form will be submitted
Returns:
Form: An initialized form instance ready to be rendered
"""
# We're expecting that the form_class is a subclass of
# ToggleableManyToManyFieldForm, which has a constructor that accepts
# instance, fields, and form_action.
if not issubclass(self.form_class, ToggleableManyToManyFieldForm):
msg = "form_class must be a subclass of ToggleableManyToManyFieldForm"
raise TypeError(msg)
return self.form_class(instance, field_name=field_name, form_action=form_action)
[docs] def get_header(self) -> Block:
"""
Get our card header. This consists of a toggle switch which hides/shows
unselected items and a search input that allows the user to search for
items.
Returns:
The card header block.
"""
# We have to do this import here due to circular dependencies
from ..models import model_verbose_name_plural
assert self.related_model is not None, ( # noqa: S101
"self.related_model must be defined before calling get_header"
)
return HorizontalLayoutBlock(
ToggleSwitchInputBlock(
label_text="Hide unselected",
input_name="hide-unchecked",
value="hide",
css_id=self.show_all_switch_id,
css_class="pt-3",
checked=True,
),
Block(
LabelBlock(
f"Filter {model_verbose_name_plural(self.related_model)}",
css_class="d-none",
attributes={
"for": self.filter_id,
},
),
InputBlock(
attributes={
"type": "text",
"placeholder": f"Filter {model_verbose_name_plural(self.related_model)}", # noqa: E501
},
css_id=self.filter_id,
css_class="form-control",
),
css_class="w-25",
),
justify="between",
align="center",
css_class="w-100",
)
[docs] @classmethod
def get_url_name(cls) -> str:
"""
Generate a standardized URL name for this widget's form submission endpoint.
This method constructs a URL name by combining the model name and related model
name in a standardized format. The URL name is used for routing form submissions
to the appropriate view and follows the pattern:
"{model_name}--{related_model_name}--update"
The model names are converted to lowercase with underscores using the
model_logger_name utility function.
Returns:
str: A URL name in the format "{model_name}--{related_model_name}--update"
Raises:
ValueError: If the model or field_name class attributes are not defined
AssertionError: If the :py:attr:`related_model` is ``None``
Note:
This method requires that both the `model` and `field_name` class attributes
are defined before calling.
"""
from ..models import model_logger_name
if cls.model is None:
msg = "model must be defined before calling get_url_name"
raise ValueError(msg)
if cls.field_name is None:
msg = "field_name must be defined before calling get_url_name"
raise ValueError(msg)
model_name = model_logger_name(cls.model)
related_model = cls.model._meta.get_field(cls.field_name).related_model
assert related_model is not None, ( # noqa: S101
f"related_model on field {cls.field_name} must be defined"
)
related_model_name = model_logger_name(related_model)
return f"{model_name}--{related_model_name}--update"
[docs] @classmethod
def get_urlpatterns(
cls, url_prefix: str | None = None, url_namespace: str | None = None
) -> list[URLPattern]:
"""
Build a view that will service this block and return a
:py:class:`django.urls.URLPattern` for that view that you can add to
your ``urlpatterns``.
Example::
from typing import List
from django.urls import path, URLPattern
from wildewidgets import ToggleableManyToManyFieldBlock
from .views import HomeView
from .models import MyModel
class TagsSelectorWidget(ToggleableManyToManyFieldBlock):
model = MyModel
field_name = 'tags'
app_name: str = "myapp"
urlpatterns: List[URLPattern] = [
path('', HomeView.as_view(), name='home'),
]
urlpatterns += TagsSelectorWidget.get_urlpatterns(url_namespace=app_name)
Important:
In order for this to work, you must have subclassed
:py:class:`ToggleableManyToManyFieldBlock` and defined both the
:py:attr:`model` and :py:attr:`field_name` attributes.
Keyword Args:
url_prefix: a prefix to the path we will build for our view
url_namespace: the namespace for our url pattern. We'll set our
:py:attr:`url_namespace` from this
Returns:
A list of urlpatterns for a view suitable for this block
"""
from ..models import model_logger_name
from ..views.generic import ManyToManyRelatedFieldView
if cls.model is None:
msg = 'Define the "model" class attribute before calling "get_urlpattern"'
raise ValueError(msg)
if cls.field_name is None:
msg = (
'Define the "field_name" class attribute before calling '
'"get_urlpattern"'
)
raise ValueError(msg)
model_name = model_logger_name(cls.model)
related_model = cls.model._meta.get_field(cls.field_name).related_model
assert related_model is not None, ( # noqa: S101
f"related_model on field {cls.field_name} must be defined"
)
related_model_name = model_logger_name(related_model)
if url_namespace:
cls.url_namespace = url_namespace
if not url_prefix:
url_prefix = cls.url_prefix
elif not url_prefix.endswith("/"):
url_prefix = f"{url_prefix}/"
view_path = path(
f"{url_prefix}wildewidgets/{model_name}/<int:pk>/{related_model_name}/",
ManyToManyRelatedFieldView.as_view(
model=cls.model, field_name=cls.field_name
),
name=cls.get_url_name(),
)
return [view_path]