Source code for wildewidgets.widgets.structure

from __future__ import annotations

import random
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Final
from urllib.parse import urlencode

from django.core.exceptions import ImproperlyConfigured
from django.core.paginator import InvalidPage, Paginator
from django.http import Http404

from .base import Block, Widget
from .buttons import FormButton
from .headers import BasicHeader, CardHeader
from .text import HTMLWidget

if TYPE_CHECKING:
    from django.db.models import Model, QuerySet


[docs]class TabConfig: """ Used to configure the tabs in a :py:class:`PageTabbedWidget`. This class holds configuration for an individual tab within a tabbed interface, including its name, active state, and optional link. Args: name: The display name of the tab widget: Optional block to display when this tab is active active: Whether this tab should be initially active link: Optional URL for linking to another page Attributes: name: The display name of the tab active: Whether this tab is active (selected) link: URL for the tab if it links to another page """ def __init__( self, name: str, widget: Block | None = None, # noqa: ARG002 active: bool = False, link: str | None = None, ): self.name = name # TODO: why are we not using widget here? # self.widget = widget self.active = active self.link = link
[docs]class PageTabbedWidget(Block): """ Implements a `Tabler Tabbed Card <https://preview.tabler.io/docs/cards.html>`_. This widget creates a tabbed interface where the active tab displays a widget, and other tabs are links to other pages. When those links are followed, the corresponding tab becomes active on the new page. This way, only the active widget needs to be rendered. Example: .. code-block:: python from wildewidgets import PageTabbedWidget, TabConfig, Block tab = PageTabbedWidget() tab.add_link_tab('My First Tab', 'my_first_page_url') tab.add_link_tab('My Second Tab', 'my_second_page_url') tab.add_active_tab('My Third Tab', widget) Args: *blocks: Child blocks to include in the tabbed interface Keyword Args: slug_suffix: Optional suffix for the slug in the URL to differentiate between multiple instances of this widget on the same page overflow: CSS overflow attribute for the widget (default is "auto") **kwargs: Additional keyword arguments passed to the parent class """ #: The Django template to use for rendering this widget. template_name: str = "wildewidgets/page_tab_block.html" #: The name of the block in the template for CSS styling. block: str = "card" #: The suffix to use for the slug in the URL. This is used to #: differentiate between multiple instances of this widget on the same page. #: This is set to a random number between 0 and 10000 if not provided. slug_suffix: str | None = None #: The CSS overflow attribute for the widget. overflow: str = "auto" def __init__( self, *blocks, slug_suffix: str | None = None, overflow: str | None = None, **kwargs, ): self.slug_suffix = slug_suffix or self.slug_suffix self.overflow = overflow or self.overflow super().__init__(*blocks, **kwargs) if "style" in self._attributes: self._attributes["style"] += f" overflow: {self.overflow};" else: self._attributes["style"] = f"overflow: {self.overflow};" self.tabs: list[TabConfig] = [] self.widget: Block | None = None
[docs] def add_active_tab(self, name: str, widget: Block) -> None: """ Add a tab that displays a widget. Creates a tab that is initially active and displays the specified widget. Args: name: The display name of the tab widget: The widget to display when this tab is active """ tab = TabConfig(name=name, active=True) self.tabs.append(tab) self.widget = widget
[docs] def get_context_data(self, *args, **kwargs): """ Prepare the context data for template rendering. Adds the tabs, widget, and unique identifier to the template context. Args: *args: Positional arguments passed to parent method **kwargs: Keyword arguments passed to parent method Returns: dict: The updated context dictionary with tab-specific data """ kwargs["tabs"] = self.tabs if not self.slug_suffix: self.slug_suffix = random.randrange(0, 10000) # noqa: S311 kwargs["identifier"] = self.slug_suffix kwargs["widget"] = self.widget return super().get_context_data(*args, **kwargs)
[docs]class TabbedWidget(Block): """ Implements a `Tabler Tabbed Card <https://preview.tabler.io/docs/cards.html>`_. This widget creates a tabbed interface where all tabs are rendered client-side (unlike PageTabbedWidget which only renders the active tab). The tabs are switched using JavaScript, without page navigation. Example: .. code-block:: python from wildewidgets import TabbedWidget, Block widget1 = Block("This is the first widget") widget2 = Block("This is the second widget") tab = TabbedWidget() tab.add_tab('My First Widget', widget1) tab.add_tab('My Second Widget', widget2) Args: *blocks: Child blocks to include in the tabbed interface Keyword Args: slug_suffix: Optional suffix for the slug in the URL to differentiate between multiple instances of this widget on the same page overflow: CSS overflow attribute for the widget (default is "auto") **kwargs: Additional keyword arguments passed to the parent class """ #: The Django template to use for rendering this widget. template_name: str = "wildewidgets/tab_block.html" #: The name of the block in the template for CSS styling. block: str = "card" #: The suffix to use for the slug in the URL. This is used to #: differentiate between multiple instances of this widget on the same page. #: If not provided, a random number between 0 and 10000 will be used. #: This is used to ensure that the tabs are unique on the page. slug_suffix: str | None = None #: The CSS overflow attribute for the widget. overflow: str = "auto" def __init__( self, *blocks, slug_suffix: str | None = None, overflow: str | None = None, **kwargs, ): self.slug_suffix = slug_suffix or self.slug_suffix self.overflow = overflow or self.overflow super().__init__(*blocks, **kwargs) if "style" in self._attributes: self._attributes["style"] += f" overflow: {self.overflow};" else: self._attributes["style"] = f"overflow: {self.overflow};" #: A list of tuples containing the name of the tab and the widget to #: display in that tab. Each tuple is of the form (name, widget), where #: `name` is a string and `widget` is a Block. self.tabs: list[tuple[str, Block]] = []
[docs] def add_tab(self, name: str, widget: Block) -> None: """ Add a tab to the tabbed interface. Creates a tab with the specified name that displays the specified widget when the tab is selected. Args: name: The display name of the tab widget: The widget to display when this tab is selected """ self.tabs.append((name, widget))
[docs] def get_context_data(self, *args, **kwargs): """ Prepare the context data for template rendering. Adds the tabs and unique identifier to the template context. Args: *args: Positional arguments passed to parent method **kwargs: Keyword arguments passed to parent method Returns: dict: The updated context dictionary with tab-specific data """ kwargs["tabs"] = self.tabs if not self.slug_suffix: self.slug_suffix = random.randrange(0, 10000) # noqa: S311 kwargs["identifier"] = self.slug_suffix # TODO: why is overflow not in kwargs? # kwargs['overflow'] = self.overflow return super().get_context_data(*args, **kwargs)
[docs]class CardWidget(Block): """ Renders a `Bootstrap 5 Card <https://getbootstrap.com/docs/5.2/components/card/>`_. This widget creates a Bootstrap card component with optional header, title, subtitle, and body content. Attributes: template_name: The Django template to use for rendering this widget block: The name of the block in the template for CSS styling header: Optional header widget to display at the top of the card header_text: Optional text to use for a simple header (if header is not provided) header_css: Optional CSS classes to apply to the header card_title: Optional title for the card card_subtitle: Optional subtitle for the card widget: The main widget to display in the card body (required) widget_css: Optional CSS classes to apply to the widget overflow: CSS overflow attribute for the card Example: .. code-block:: python from wildewidgets import CardWidget, Block # Simple card with just a widget card = CardWidget( widget=Block("Card content"), card_title="My Card", card_subtitle="Card subtitle" ) # Card with a header and custom widget CSS card = CardWidget( header_text="Card Header", widget=Block("Card content"), widget_css="p-4" ) Args: *blocks: Child blocks to include in the card body Keyword Args: header: Optional header widget to display at the top of the card header_text: Optional text to use for a simple header (if header is not provided) header_css: Optional CSS classes to apply to the header card_title: Optional title for the card card_subtitle: Optional subtitle for the card widget: The main widget to display in the card body (required) widget_css: Optional CSS classes to apply to the widget overflow: CSS overflow attribute for the card """ template_name: str = "wildewidgets/card_block.html" block: str = "card" header: BasicHeader | None = None header_text: str | None = None header_css: str | None = None card_title: str | None = None card_subtitle: str | None = None widget: Widget | None = None widget_css: str | None = None overflow: str = "auto" def __init__( self, *blocks, header: BasicHeader | None = None, header_text: str | None = None, header_css: str | None = None, card_title: str | None = None, card_subtitle: str | None = None, widget: Widget | None = None, widget_css: str | None = None, overflow: str | None = None, **kwargs, ): self.header = header or deepcopy(self.header) self.header_text = header_text or self.header_text self.header_css = header_css or self.header_css if self.header_text and not self.header: self.header = CardHeader(header_text=self.header_text) self.card_title = card_title or self.card_title self.card_subtitle = card_subtitle or self.card_subtitle self.widget = widget or deepcopy(self.widget) self.overflow = overflow or self.overflow self.widget_css = widget_css or self.widget_css super().__init__(*blocks, **kwargs) if "style" in self._attributes: self._attributes["style"] += f" overflow: {self.overflow};" else: self._attributes["style"] = f"overflow: {self.overflow};"
[docs] def get_context_data(self, *args, **kwargs): """ Prepare the context data for template rendering. Adds the card components (header, title, subtitle, widget) to the context. Args: *args: Positional arguments passed to parent method **kwargs: Keyword arguments passed to parent method Returns: dict: The updated context dictionary with card-specific data Raises: ImproperlyConfigured: If no widget is defined for the card """ kwargs = super().get_context_data(*args, **kwargs) kwargs["header"] = self.header kwargs["header_text"] = self.header_text kwargs["header_css"] = self.header_css kwargs["title"] = self.card_title kwargs["subtitle"] = self.card_subtitle kwargs["widget"] = self.widget kwargs["widget_css"] = self.widget_css kwargs["css_class"] = self.css_class if not self.widget: msg = "You must define a widget." raise ImproperlyConfigured(msg) return kwargs
[docs] def set_widget(self, widget, css_class=None): """ Set or replace the main widget displayed in the card body. Args: widget: The widget to display in the card body css_class: Optional CSS classes to apply to the widget """ self.widget = widget self.widget_css = css_class
[docs] def set_header(self, header): """ Set or replace the header widget displayed at the top of the card. Args: header: The header widget to display """ self.header = header
[docs]class MultipleModelWidget(Block): """ Base class for widgets that display multiple model instances. This abstract class provides common functionality for widgets that display a list of model instances, such as PagedModelWidget and ListModelWidget. Note: Either model or queryset must be provided, but not both. Example: .. code-block:: python from wildewidgets import MultipleModelWidget from myapp.models import MyModel from myapp.widgets import MyModelWidget class MyListWidget(MultipleModelWidget): model = MyModel model_widget = MyModelWidget ordering = "-created_at" item_label = "item" Args: *blocks: Child blocks to include in the widget Keyword Args: model: The Django model class to query for instances queryset: A pre-defined queryset to use for fetching model instances ordering: The ordering to apply to the queryset (default is None) model_widget: The widget class to use for rendering each model instance model_kwargs: Optional keyword arguments to pass to the model widget item_label: The label to use for each instance (default is "item") Raises: ImproperlyConfigured: If both model and queryset are defined, or if neither is defined, or if model_widget is not defined. """ #: If this is defined, do ``self.model.objects.all()`` to get our list of instances. #: Define either this or :py:attr:`queryset`, but not both. model: type[Model] | None = None #: Use this queryset to get our list of instances. Define either this or #: :py:attr:`model`, but not both. queryset: QuerySet | None = None #: The ordering to use for the queryset. This will be applied to both #: :py:attr:`model` and :py:attr:`queryset` ordering: str | tuple[str, ...] | None = None #: Use this widget class to render each model instance. model_widget: type[Widget] | None = None #: When instantiating :py:attr:`model_widget`, pass this dict into the #: widget constructor as the keyword arguments model_kwargs: dict[str, Any] = {} # noqa: RUF012 #: The label to use for each instance. This is used in the confirmation #: dialog when deleting instances. item_label: str = "item" def __init__( self, *blocks, model: type[Model] | None = None, queryset: QuerySet | None = None, ordering: str | None = None, model_widget: type[Widget] | None = None, model_kwargs: dict[str, Any] | None = None, item_label: str | None = None, **kwargs, ) -> None: self.model = model or self.model self.queryset = queryset if queryset is not None else self.queryset if self.model and self.queryset: msg = "You must define either model or queryset, but not both." raise ImproperlyConfigured(msg) self.model_widget = model_widget or deepcopy(self.model_widget) self.model_kwargs = model_kwargs or deepcopy(self.model_kwargs) self.ordering = ordering or self.ordering self.item_label = item_label or self.item_label super().__init__(*blocks, **kwargs)
[docs] def get_item_label(self, instance: Model) -> str: # noqa: ARG002 """ Get the label to use for a specific model instance. This label is used in confirmation dialogs and other UI elements. Args: instance: The model instance to get the label for Returns: str: The label for the instance (defaults to the class's item_label) """ # TODO: why is instance an argument here? It is not used here. return self.item_label
[docs] def get_model_widget(self, object: Model, **kwargs) -> Widget: # noqa: A002 """ Create a widget for a specific model instance. This method creates and returns a widget for displaying a single model instance. Args: object: The model instance to create a widget for **kwargs: Additional keyword arguments to pass to the widget constructor Returns: Widget: The widget for displaying the model instance Raises: ImproperlyConfigured: If model_widget is not defined and this method is not overridden """ if self.model_widget: return self.model_widget(object=object, **kwargs) msg = ( f"{self.__class__.__name__} is missing a model widget. Define " f"{self.__class__.__name__}.model_widget or override " f"{self.__class__.__name__}.get_model_widget()." ) raise ImproperlyConfigured(msg)
[docs] def get_model_widgets(self, instances: list[Model]) -> list[Widget]: """ Create widgets for a list of model instances. Args: instances: List of model instances to create widgets for Returns: list[Widget]: List of widgets for displaying the model instances """ return [ self.get_model_widget(object=instance, **self.model_kwargs) for instance in instances ]
[docs] def get_queryset(self) -> list[Model] | QuerySet: """ Get the queryset of model instances to display. This method fetches the model instances to display, either from the provided queryset or by querying the model. Returns: list[Model] | QuerySet: The model instances to display Raises: ImproperlyConfigured: If neither model nor queryset is defined """ if self.queryset is not None: queryset = self.queryset queryset = queryset.all() elif self.model is not None: # TODO: why are we using _default_manager here instead of .objects ? # The class may have replaced .objects with a more targeted manager and # we won't get that if we always hit _default_manager queryset = self.model._default_manager.all() else: msg = ( f"{self.__class__.__name__} is missing a QuerySet. Define " f"{self.__class__.__name__}.model, {self.__class__.__name__}.queryset, " f"or override {self.__class__.__name__}.get_queryset()." ) raise ImproperlyConfigured(msg) ordering = self.ordering if ordering: if isinstance(ordering, str): ordering = (ordering,) queryset = queryset.order_by(*ordering) return queryset
[docs]class PagedModelWidget(MultipleModelWidget): """ A widget that displays a paginated list of model instances. This widget creates a paginated list of widgets, with each widget displaying a single model instance. It includes pagination controls for navigating between pages. Example: .. code-block:: python from wildewidgets import PagedModelWidget, MyObjectWidget widget = PagedModelWidget( queryset=mymodel.myobject_set.all(), paginate_by=10, page_kwarg='page', model_widget=MyObjectWidget, model_kwargs={'foo': 'bar'}, item_label="object", extra_url={ 'pk': mymodel.id, '#': 'objects', }, ) Args: *blocks: Child blocks to include in the widget Keyword Args: model: The Django model class to query for instances (optional) queryset: A pre-defined queryset to use for fetching model instances ordering: The ordering to apply to the queryset (default is None) model_widget: The widget class to use for rendering each model instance model_kwargs: Optional keyword arguments to pass to the model widget item_label: The label to use for each instance (default is "item") extra_url: Additional URL parameters to include in pagination links page_kwarg: The GET parameter name for the page number (default is "page") paginate_by: Number of items per page (default is 25) max_page_controls: Maximum number of page controls to display (default is 5)s """ #: The Django template to use for rendering this widget. template_name: str = "wildewidgets/paged_model_widget.html" #: The name of the block in the template for CSS styling. block: str = "wildewidgets-paged-model-widget" #: The get argument for the page number. Defaults to `page`. page_kwarg: str = "page" #: Number of widgets per page. Defaults to all widgets. paginate_by: int = 25 #: Extra parameters passed in the page links. max_page_controls: int = 5 def __init__( self, *blocks, page_kwarg: str | None = None, paginate_by: int | None = None, extra_url: dict[str, Any] | None = None, **kwargs, ): self.page_kwarg = page_kwarg or self.page_kwarg self.paginate_by = paginate_by or self.paginate_by self.extra_url = extra_url or {} super().__init__(*blocks, **kwargs)
[docs] def get_context_data(self, *args, **kwargs): """ Prepare the context data for template rendering. This method: 1. Handles pagination of the model instances 2. Creates widgets for the current page of instances 3. Prepares pagination controls and context 4. Adds any extra URL parameters for pagination links Args: *args: Positional arguments passed to parent method **kwargs: Keyword arguments passed to parent method Returns: dict: The updated context dictionary with pagination-specific data Raises: Http404: If an invalid page number is requested """ kwargs = super().get_context_data(*args, **kwargs) self.request = kwargs.get("request") if self.paginate_by: paginator = Paginator(self.get_queryset(), self.paginate_by) page_number = self.request.GET.get(self.page_kwarg) try: page_number = int(page_number) except (TypeError, ValueError): page_number = 1 try: page = paginator.page(page_number) except InvalidPage as e: msg = f"Invalid page ({page_number}): {e!s}" raise Http404(msg) from e kwargs["widget_list"] = self.get_model_widgets(page.object_list) kwargs["page_obj"] = page kwargs["is_paginated"] = page.has_other_pages() kwargs["paginator"] = paginator kwargs["page_kwarg"] = self.page_kwarg pages = kwargs["page_obj"].paginator.num_pages pages = min(pages, self.max_page_controls) page_number = page.number max_controls_half = int(self.max_page_controls / 2) range_start = max(page_number - max_controls_half, 1) kwargs["page_range"] = range(range_start, range_start + pages) else: kwargs["widget_list"] = self.get_model_widgets(self.get_queryset().all()) kwargs["item_label"] = self.item_label if self.extra_url: anchor = self.extra_url.pop("#", None) extra_url = f"&{urlencode(self.extra_url)}" if anchor: extra_url = f"{extra_url}#{anchor}" kwargs["extra_url"] = extra_url else: kwargs["extra_url"] = "" return kwargs
[docs]class CollapseWidget(Block): """ A `Bootstrap Collapse widget <https://getbootstrap.com/docs/5.2/components/collapse/>`_. This widget creates a collapsible content area that can be toggled open or closed by a CollapseButton or other trigger element. It's useful for hiding content that isn't immediately relevant but might be needed later. Note: A `CollapseWidget` needs a trigger element (like a :py:class:`wildewidgets.CollapseButton`) with the `data-toggle="collapse"` attribute and a `data-target` pointing to this widget's ID. Example: .. code-block:: python from wildewidgets import CollapseWidget, CardHeader, CrispyFormWidget collapse_id = 'my-collapse-id' collapse = CollapseWidget( CrispyFormWidget(form=form), css_id=collapse_id, css_class="pt-3", ) header = CardHeader(header_text="Services") header.add_collapse_button( text="Manage", color="primary", target=f"#{collapse_id}", ) """ #: The Django template to use for rendering this widget. block: str = "collapse"
[docs]class HorizontalLayoutBlock(Block): """ A container that aligns child widgets horizontally using flexbox. This widget creates a horizontal layout for its child widgets using Bootstrap's flexbox utilities. It provides options for controlling vertical and horizontal alignment, as well as responsive behavior. Example: .. code-block:: python from wildewidgets import HorizontalLayoutBlock, LabelBlock, Block # Create a layout with right-aligned items and centered vertical alignment layout = HorizontalLayoutBlock( LabelBlock("Label"), Block("Content 1"), Block("Content 2"), justify="end", align="center", flex_size="md", # Stack vertically on screens smaller than medium css_class="mt-3" ) Args: *blocks: Child blocks to include in the horizontal layout Keyword Args: align: Vertical alignment of items justify: Horizontal alignment of items flex_size: Bootstrap viewport size below which items stack vertically **kwargs: Additional keyword arguments passed to the parent class """ #: The valid column content ``justify-content-`` values VALID_JUSTIFICATIONS: Final[list[str]] = [ "start", "center", "end", "between", "around", "evenly", ] #: The valid column content ``justify-content-`` values VALID_ALIGNMENTS: Final[list[str]] = [ "start", "center", "end", "baseline", "stretch", ] #: How to align items veritcally within this widget. Valid choices: ``start``, #: ``center``, ``end``, ``baselin``, ``stretch``. See `Bootstrap: Flex, #: justify content <https://getbootstrap.com/docs/5.2/utilities/flex/#align-items>`_. #: If not supplied here and :py:attr:`align` is ``None``, do whatever #: vertical aligment Bootstrap does by default. align: str = "center" #: How to align items horizontally within this widget. Valid choices: #: ``start``, : ``center``, ``end``, ``between``, ``around``, ``evenly``. See #: `Bootstrap: Flex, justify content #: <https://getbootstrap.com/docs/5.2/utilities/flex/#justify-content>`_. #: If not supplied here and :py:attr:`justify` is ``None``, do whatever #: horizontal aligment Bootstrap does by default. justify: str = "between" #: the Boostrap viewport size below which this will be a vertical list instead #: of a horizontal one. flex_size: str | None = None def __init__( self, *blocks, align: str | None = None, justify: str | None = None, flex_size: str | None = None, **kwargs, ): self.align = align or self.align self.justify = justify or self.justify self.flex_size = flex_size or self.flex_size if self.align not in self.VALID_ALIGNMENTS: msg = ( f'"{self.align}" is not a valid vertical alignment value. Valid ' f"values are {', '.join(self.VALID_ALIGNMENTS)}" ) raise ValueError(msg) if self.justify not in self.VALID_JUSTIFICATIONS: msg = ( f'"{self.justify}" is not a valid horizontal alignment value. Valid ' f"values are {', '.join(self.VALID_JUSTIFICATIONS)}" ) raise ValueError(msg) super().__init__(*blocks, **kwargs) flex = f"d-{self.flex_size}-flex" if self.flex_size else "d-flex" self.add_class(flex) self.add_class(f"align-items-{self.align}") self.add_class(f"justify-content-{self.justify}")
[docs]class ListModelWidget(MultipleModelWidget): """ A widget that displays a list of model instances. This widget creates an unordered list of items, with each item displaying a single model instance. It can optionally include buttons for removing items. Example: .. code-block:: python from wildewidgets import ListModelWidget from django.urls import reverse # Create a list with remove buttons widget = ListModelWidget( queryset=parent.children.all(), item_label='child', remove_url=reverse('remove_child') + "?id={}", ) # Create a read-only list with custom model widget widget = ListModelWidget( queryset=Author.objects.all(), model_widget=AuthorWidget, item_label='author' ) Args: *args: Positional arguments passed to parent class Keyword Args: remove_url: Optional URL format string for removing items (with {} placeholder for ID) **kwargs: Keyword arguments passed to parent class Raises: ValueError: If model_widget is not defined and no model instance is provided ImproperlyConfigured: If neither model nor queryset is defined """ #: The name of the block in the template for CSS styling. block: str = "list-group" #: Another name for this widget, used in addition to the block name. name: str = "wildewidgets-list-model-widget" #: The HTML tag to use for the container tag: str = "ul" #: If True, show a message when there are no items in the list. show_no_items: bool = True #: The url to "POST" to in order to delete or remove the object. remove_url: str | None = None def __init__(self, *args, remove_url: str | None = None, **kwargs: Any) -> None: self.remove_url = remove_url or self.remove_url super().__init__(*args, **kwargs) result = self.get_queryset() if not isinstance(result, list): result = list(result.all()) widgets = self.get_model_widgets(result) if not widgets and self.show_no_items: self.add_block( Block( f"No {self.item_label}s", tag="li", name="list-group-item", css_class="fw-light fst-italic border", ) ) for widget in widgets: self.add_block(widget)
[docs] def get_remove_url(self, instance: Model) -> str: """ Get the URL for removing a specific model instance. Args: instance: The model instance to get the remove URL for Returns: str: The URL for removing the instance, or an empty string if not configured """ if self.remove_url: return self.remove_url.format(instance.id) return ""
[docs] def get_confirm_text(self, instance: Model) -> str: """ Get the confirmation text for removing a specific model instance. This text is used in the confirmation dialog when removing an item. Args: instance: The model instance to get the confirmation text for Returns: str: The confirmation text for removing the instance """ item_label = self.get_item_label(instance) return f"Are you sure you want to remove this {item_label}?"
[docs] def get_object_text(self, instance: Model) -> str: """ Get the display text for a specific model instance. Args: instance: The model instance to get the display text for Returns: str: The display text for the instance (defaults to str(instance)) """ return str(instance)
[docs] def get_model_subblock(self, instance: Model): """ Create a block for displaying a model instance. If the model instance has a get_absolute_url method, the text will be wrapped in a link to that URL. Args: instance: The model instance to create a block for Returns: Block: A block for displaying the model instance """ if hasattr(instance, "get_absolute_url"): url = instance.get_absolute_url() # TODO: use a Link here return Block( HTMLWidget( html=f'<a href="{url}"><label>{self.get_object_text(instance)}' "</label></a>" ) ) return Block(self.get_object_text(instance), tag="label")
[docs] def get_model_widget(self, object: Model, **kwargs) -> Widget: # type: ignore[override] # noqa: A002 """ Create a widget for a specific model instance. If model_widget is defined, it uses that. Otherwise, it creates a default widget with the instance text and an optional remove button. Args: object: The model instance to create a widget for Keyword Args: **kwargs: Additional keyword arguments for the widget Returns: Widget: The widget for displaying the model instance Raises: ValueError: If obj is None and model_widget is not defined """ if self.model_widget: return super().get_model_widget(object=object, **kwargs) if object is None: if not self.model_widget: msg = ( f"{self.__class__.__name__} is missing a model widget. Define " f"{self.__class__.__name__}.model_widget or override " f"{self.__class__.__name__}.get_model_widget()." ) raise ValueError(msg) return self.model_widget(**kwargs) widget = HorizontalLayoutBlock( tag="li", name="list-group-item listmodelwidget__item" ) widget.add_block(self.get_model_subblock(object)) remove_url = self.get_remove_url(object) if remove_url: widget.add_block( FormButton( close=True, action=remove_url, confirm_text=self.get_confirm_text(object), ), ) return widget
[docs]class ListModelCardWidget(CardWidget): """ A card widget containing a filterable list of model instances. This widget creates a card with a header containing a filter input field and a body containing a list of model instances. The filter input allows users to filter the list by typing. Example: .. code-block:: python from wildewidgets import ListModelCardWidget from myapp.models import Author from myapp.widgets import AuthorListWidget # Basic usage with default list widget widget = ListModelCardWidget( queryset=Author.objects.all()[:10] ) # Custom list widget and placeholder widget = ListModelCardWidget( queryset=Author.objects.all(), list_model_widget_class=AuthorListWidget, placeholder="Search authors...", item_label="author" ) Args: *args: Positional arguments passed to parent class Keyword Args: list_model_widget_class: Optional custom widget class for the list model list_model_header_class: Optional custom header widget class placeholder: Placeholder text for the filter input field **kwargs: Keyword arguments passed to parent class """ #: The script to use for filtering the list of objects. SCRIPT: str = """ var filter_input = document.getElementById("{filter_id}"); filter_input.onkeyup = function(e) {{ var filter = e.target.value.toLowerCase(); document.querySelectorAll("{query} label").forEach(label => {{ var test_string = label.innerText.toLowerCase(); if (test_string.includes(filter)) {{ label.closest('.listmodelwidget__item').classList.remove('d-none'); }} else {{ label.closest('.listmodelwidget__item').classList.add('d-none'); }} }}); let children = document.querySelectorAll("{query} li"); for (let i=0; i < children.length; i++) {{ let child = children[i]; child.classList.remove('border-top'); }}; for (let i=0; i < children.length; i++) {{ let child = children[i]; if (child.classList.contains('d-none')) {{ }} else {{ child.classList.add('border-top'); break; }} }}; }}; """ #: The Widget subclass to use for the list model widget. list_model_widget_class: type[Widget] = ListModelWidget #: The Widget subclass to use for the header. list_model_header_class: type[Widget] | None = None def __init__( self, *args, list_model_widget_class: type[Widget] | None = None, list_model_header_class: type[Widget] | None = None, placeholder: str | None = None, **kwargs, ): self.id_base = f"list_modal_card_{random.randrange(0, 1000)}" # noqa: S311 self.list_model_widget_id = f"{self.id_base}_list_model_widget" self.filter_id = f"{self.id_base}_filter" self.list_model_widget_class = ( list_model_widget_class or self.list_model_widget_class ) self.list_model_header_class = ( list_model_header_class or self.list_model_header_class ) # Pop the kwargs that are used to build the widget and header. # These will be passed to the widget and header constructors. widget_kwargs = { "remove_url": kwargs.pop("remove_url", None), "model": kwargs.pop("model", None), "model_widget": kwargs.pop("model_widget", None), "ordering": kwargs.pop("ordering", None), "queryset": kwargs.pop("queryset", None), "model_kwargs": kwargs.pop("model_kwargs", {}), "item_label": kwargs.pop("item_label", "item"), "css_id": self.list_model_widget_id, } self.widget: Widget = self.get_list_model_widget(**widget_kwargs) self.placeholder = placeholder or f"Filter {self.widget.item_label}s" kwargs["widget"] = self.widget header_kwargs = { "title": kwargs.pop("title", ""), } kwargs["header"] = self.get_list_model_header(**header_kwargs) kwargs["header_css"] = "bg-light" filter_label_query = f"#{self.list_model_widget_id}" kwargs["script"] = self.SCRIPT.format( query=filter_label_query, filter_id=self.filter_id ) super().__init__(*args, **kwargs)
[docs] def get_list_model_widget(self, *args, **kwargs): """ Create the list model widget. Args: *args: Positional arguments for the list model widget **kwargs: Keyword arguments for the list model widget Returns: Widget: The list model widget instance """ return self.list_model_widget_class(*args, **kwargs)
[docs] def get_list_model_header(self, *args, **kwargs): """ Create the header widget with filter input. If list_model_header_class is defined, it uses that. Otherwise, it creates a default header with a filter input field. Args: *args: Positional arguments for the header **kwargs: Keyword arguments for the header Returns: Widget: The header widget instance """ from .forms import InputBlock, LabelBlock if self.list_model_header_class: return self.list_model_header_class(*args, **kwargs) # pylint: disable=not-callable return Block( Block( LabelBlock( f"Filter {self.widget.item_label}s", css_class="d-none", for_input=self.filter_id, ), InputBlock( attributes={ "type": "text", "placeholder": self.placeholder, }, css_id=f"{self.id_base}_filter", css_class="form-control", ), css_class="w-25", ), Block(""), css_class="d-flex flex-row-reverse w-100", )