diff --git a/bread/contrib/reports/urls.py b/bread/contrib/reports/urls.py index 76514042..68eed44c 100644 --- a/bread/contrib/reports/urls.py +++ b/bread/contrib/reports/urls.py @@ -152,7 +152,7 @@ def exceldownload(request, pk: int): Report, browseview=views.BrowseView._with( columns=["name", "created"], - rowclickaction="read", + rowclickaction=views.BrowseView.gen_rowclickaction("read"), bulkactions=[ views.browse.BulkAction( "delete", label=_("Delete"), iconname="trash-can", action=delete diff --git a/bread/contrib/workflows/models/base.py b/bread/contrib/workflows/models/base.py index 5066c732..9071c94e 100644 --- a/bread/contrib/workflows/models/base.py +++ b/bread/contrib/workflows/models/base.py @@ -273,12 +273,12 @@ def cancel(self, save=True): self.save() def save(self, *args, **kwargs): - self.update_workflow_state() + self.update_workflow_state(runactions=True) if not self.completed and not self.cancelled and self.done: self.completed = timezone.now() super().save(*args, **kwargs) - def update_workflow_state(self): + def update_workflow_state(self, runactions=False): # don't do anything on the workflow after it has been cancelled if self.cancelled: return @@ -294,7 +294,11 @@ def update_workflow_state(self): node = nodequeue.pop() if not node.done(self): if isinstance(node, Action): - if any(n.done(self) for n, c in node.inputs): + if ( + node.hasincoming(self) + and not getattr(self, node.name) + and runactions + ): actionresult = node.action(self) if actionresult != getattr(self, node.name): state_changed = True diff --git a/bread/contrib/workflows/views.py b/bread/contrib/workflows/views.py index 06db9b24..cf20c5b8 100644 --- a/bread/contrib/workflows/views.py +++ b/bread/contrib/workflows/views.py @@ -5,7 +5,7 @@ class WorkflowBrowseView(views.BrowseView): - rowclickaction = "edit" + rowclickaction = views.BrowseView.gen_rowclickaction("edit") def get_layout(self): workflow_diagram = layout.modal.Modal( @@ -33,13 +33,18 @@ def get_layout(self): fields = hg.BaseElement( *[layout.form.FormField(f) for f in self.object.active_fields()] ) - return hg.DIV( + return hg.BaseElement( + hg.H1(self.object, style="margin-bottom: 2rem"), hg.DIV( - layout.form.Form.wrap_with_form(hg.C("form"), fields), - style="padding: 1rem", + hg.DIV( + layout.form.Form.wrap_with_form(hg.C("form"), fields), + style="padding: 1rem", + ), + hg.DIV( + self.object.as_svg(), style="width: 40%; border: 1px solid gray" + ), + style="display: flex", ), - hg.DIV(self.object.as_svg(), style="width: 40%; border: 1px solid gray"), - style="display: flex", ) @@ -52,22 +57,28 @@ def get_layout(self): ] ) - return hg.DIV( - layout.button.Button( - "Edit", - **layout.aslink_attributes(layout.objectaction(self.object, "edit")), - ), - views.layoutasreadonly( - hg.DIV( - hg.DIV( - layout.form.Form.wrap_with_form(hg.C("form"), fields), - style="padding: 1rem", + return hg.BaseElement( + hg.H1(self.object, style="margin-bottom: 2rem"), + hg.DIV( + layout.button.Button( + "Edit", + **layout.aslink_attributes( + layout.objectaction(self.object, "edit") ), + ), + views.layoutasreadonly( hg.DIV( - self.object.as_svg(), style="width: 40%; border: 1px solid gray" - ), - style="display: flex;", - ) + hg.DIV( + layout.form.Form.wrap_with_form(hg.C("form"), fields), + style="padding: 1rem", + ), + hg.DIV( + self.object.as_svg(), + style="width: 40%; border: 1px solid gray", + ), + style="display: flex;", + ) + ), ), ) diff --git a/bread/layout/__init__.py b/bread/layout/__init__.py index 504b3519..e4613e59 100644 --- a/bread/layout/__init__.py +++ b/bread/layout/__init__.py @@ -14,6 +14,7 @@ from .components import multiselect # noqa from .components import notification # noqa from .components import overflow_menu # noqa +from .components import pagination # noqa from .components import progress_indicator # noqa from .components import search # noqa from .components import search_select # noqa diff --git a/bread/layout/components/button.py b/bread/layout/components/button.py index 49cf2e12..ddbbb1d2 100644 --- a/bread/layout/components/button.py +++ b/bread/layout/components/button.py @@ -1,10 +1,6 @@ -import warnings - import htmlgenerator as hg from django.utils.translation import gettext_lazy as _ -from bread.utils.links import Link - from .icon import Icon @@ -44,24 +40,6 @@ def __init__( children += (icon,) super().__init__(*children, **attributes) - @staticmethod - def fromaction(action, **kwargs): - warnings.warn( - "Button.fromaction is going to be deprecated in the future. Try to use bread.utils.links.Link where possible" - ) - buttonargs = { - "icon": action.iconname, - "notext": not action.label, - } - if isinstance(action, Link): - buttonargs["onclick"] = hg.F( - lambda c: f"document.location = '{hg.resolve_lazy(action.href, c)}'" - ) - else: - buttonargs["onclick"] = action.js - buttonargs.update(kwargs) - return Button(*([action.label] if action.label else []), **buttonargs) - @staticmethod def fromlink(link, **kwargs): buttonargs = { @@ -69,7 +47,8 @@ def fromlink(link, **kwargs): "notext": not link.label, } return Button( - *([link.label] if link.label else []), **{**buttonargs, **kwargs} + *([link.label] if link.label else []), + **{**buttonargs, **link.attributes, **kwargs}, ).as_href(link.href) def as_href(self, href): diff --git a/bread/layout/components/datatable.py b/bread/layout/components/datatable.py index bcb80fac..3eee0411 100644 --- a/bread/layout/components/datatable.py +++ b/bread/layout/components/datatable.py @@ -8,13 +8,12 @@ from bread.utils.links import Link, ModelHref from bread.utils.urls import link_with_urlparameters -from ..base import ObjectFieldLabel, ObjectFieldValue, aslink_attributes, objectaction -from . import search +from ..base import ObjectFieldLabel, ObjectFieldValue, aslink_attributes from .button import Button from .icon import Icon from .overflow_menu import OverflowMenu -from .pagination import Pagination -from .search import Search +from .pagination import Pagination, PaginationConfig +from .search import Search, SearchBackendConfig class DataTableColumn(NamedTuple): @@ -104,31 +103,26 @@ def __init__( def with_toolbar( self, - title, - helper_text=None, - primary_button=None, - searchurl=None, - query_urlparameter=None, + title: Any, + helper_text: Any = None, + primary_button: Optional[Button] = None, + search_backend: Optional[SearchBackendConfig] = None, bulkactions: List[Link] = (), - pagination_options=(), - paginator=None, - page_urlparameter="page", - itemsperpage_urlparameter="itemsperpage", + pagination_config: Optional[PaginationConfig] = None, checkbox_for_bulkaction_name="_selected", - settingspanel=None, + settingspanel: Any = None, ): """ wrap this datatable with title and toolbar title: table title helper_text: sub title primary_button: bread.layout.button.Button instance - searchurl: url to which values entered in the searchfield should be submitted - query_urlparameter: name of the query field for the searchurl which contains the entered text + search_backend: search endpoint bulkactions: List of bread.utils.links.Link instances. Will send a post or a get (depending on its "method" attribute) to the target url the sent data will be a form with the selected checkboxes as fields if the head-checkbox has been selected only that field will be selected """ checkboxallid = f"datatable-check-{hg.html_id(self)}" header = [hg.H4(title, _class="bx--data-table-header__title")] - if helper_text: + if helper_text is not None: header.append( hg.P(helper_text, _class="bx--data-table-header__description") ) @@ -232,24 +226,22 @@ def with_toolbar( hg.DIV( Search( widgetattributes={"autofocus": True}, - backend=search.SearchBackendConfig( - url=searchurl, query_parameter=query_urlparameter - ), + backend=search_backend, resultcontainerid=resultcontainerid, show_result_container=False, ), _class="bx--toolbar-search-container-persistent", ) - if searchurl - else "", + if search_backend + else None, Button( icon="settings--adjust", buttontype="ghost", onclick="this.parentElement.parentElement.parentElement.querySelector('.settingscontainer').style.display = this.parentElement.parentElement.parentElement.querySelector('.settingscontainer').style.display == 'block' ? 'none' : 'block'; event.stopPropagation()", ) if settingspanel - else "", - primary_button or "", + else None, + primary_button or None, _class="bx--toolbar-content", ), _class="bx--table-toolbar", @@ -276,18 +268,7 @@ def with_toolbar( onclick="event.stopPropagation()", ), self, - *( - [ - Pagination( - paginator, - pagination_options, - page_urlparameter=page_urlparameter, - itemsperpage_urlparameter=itemsperpage_urlparameter, - ) - ] - if paginator - else [] - ), + Pagination.from_config(pagination_config) if pagination_config else None, _class="bx--data-table-container", data_table=True, ) @@ -296,37 +277,36 @@ def with_toolbar( def from_model( model, queryset=None, - columns: Union[List[str], List[DataTableColumn]] = None, + # column behaviour + columns: List[Union[str, DataTableColumn]] = None, + prevent_automatic_sortingnames=False, + # row behaviour + rowvariable="row", rowactions: List[Link] = (), rowactions_dropdown=False, + rowclickaction=None, + # bulkaction behaviour bulkactions: List[Link] = (), + checkbox_for_bulkaction_name="_selected", + # toolbar configuration title=None, primary_button: Optional[Button] = None, - searchurl=None, - query_urlparameter=None, - rowclickaction=None, - prevent_automatic_sortingnames=False, - with_toolbar=True, - pagination_options=(), - paginator=None, - page_urlparameter="page", - itemsperpage_urlparameter="itemsperpage", - checkbox_for_bulkaction_name="_selected", - settingspanel=None, - rowvariable="row", + settingspanel: Any = None, + search_backend: Optional[SearchBackendConfig] = None, + pagination_config: Optional[PaginationConfig] = None, **kwargs, ): """TODO: Write Docs!!!! Yeah yeah, on it already... - :param hg.BaseElement settingspanel: A panel which will be opened when clicking on the - "Settings" button of the datatable, usefull e.g. - for showing filter options. Currently only one - button and one panel are supported. More buttons - and panels could be interesting but may to over- - engineered because it is a rare case and it is not - difficutl to add another button by modifying the - datatable after creation. + :param settingspanel: A panel which will be opened when clicking on the + "Settings" button of the datatable, usefull e.g. + for showing filter options. Currently only one + button and one panel are supported. More buttons + and panels could be interesting but may to over- + engineered because it is a rare case and it is not + difficutl to add another button by modifying the + datatable after creation. """ for col in columns: if not (isinstance(col, DataTableColumn) or isinstance(col, str)): @@ -375,11 +355,13 @@ def from_model( for col in columns: td_attributes = None if rowclickaction and getattr(col, "enable_row_click", True): - td_attributes = hg.F( - lambda c: aslink_attributes( - objectaction(c[rowvariable], rowclickaction) - ) - ) + assert isinstance( + rowclickaction, Link + ), "rowclickaction must be of type Link" + td_attributes = { + **aslink_attributes(rowclickaction.href), + **(rowclickaction.attributes or {}), + } # convert simple string (modelfield) to column definition if isinstance(col, str): col = DataTableColumn.from_modelfield( @@ -395,7 +377,7 @@ def from_model( column_definitions.append(col) - table = DataTable( + return DataTable( column_definitions + ( [ @@ -420,29 +402,26 @@ def from_model( # querysets are cached, the call to all will make sure a new query is used in every request hg.F(lambda c: queryset), **kwargs, + ).with_toolbar( + title, + helper_text=hg.format( + "{} {}", + hg.F(lambda c: len(hg.resolve_lazy(queryset, c))), + model._meta.verbose_name_plural, + ), + primary_button=primary_button, + search_backend=search_backend, + bulkactions=bulkactions, + pagination_config=pagination_config, + checkbox_for_bulkaction_name=checkbox_for_bulkaction_name, + settingspanel=settingspanel, ) - if with_toolbar: - table = table.with_toolbar( - title, - primary_button=primary_button, - searchurl=searchurl, - bulkactions=bulkactions, - pagination_options=pagination_options, - page_urlparameter=page_urlparameter, - query_urlparameter=query_urlparameter, - paginator=paginator, - itemsperpage_urlparameter=itemsperpage_urlparameter, - checkbox_for_bulkaction_name=checkbox_for_bulkaction_name, - settingspanel=settingspanel, - ) - return table @staticmethod def from_queryset( queryset, **kwargs, ): - """TODO: Write Docs!!!!""" return DataTable.from_model( queryset.model, queryset=queryset, diff --git a/bread/layout/components/overflow_menu.py b/bread/layout/components/overflow_menu.py index 0e836f0f..40f6d87a 100644 --- a/bread/layout/components/overflow_menu.py +++ b/bread/layout/components/overflow_menu.py @@ -3,6 +3,25 @@ from .icon import Icon +def asoverflowbutton(context): + link = context["link"] + return hg.A( + hg.DIV( + hg.If( + link.iconname, + Icon(link.iconname, size=16), + ), + link.label, + _class="bx--overflow-menu-options__option-content", + ), + _class="bx--overflow-menu-options__btn", + role="menuitem", + title=link.label, + href=link.href, + **link.attributes, + ) + + class OverflowMenu(hg.DIV): """Implements https://www.carbondesignsystem.com/components/overflow-menu/usage""" @@ -10,7 +29,7 @@ class OverflowMenu(hg.DIV): def __init__( self, - actions, + links, menuiconname="overflow-menu--vertical", menuname=None, direction="bottom", @@ -18,7 +37,6 @@ def __init__( item_attributes={}, **attributes, ): - """actions: an iterable which contains bread.menu.Action objects where the onclick value is what will be passed to the onclick attribute of the menu-item (and therefore should be javascript, e.g. "window.location.href='/home'").""" attributes["data-overflow-menu"] = True attributes["_class"] = attributes.get("_class", "") + " bx--overflow-menu" item_attributes["_class"] = ( @@ -51,24 +69,10 @@ def __init__( hg.DIV( hg.UL( hg.Iterator( - actions, - "action", + links, + "link", hg.LI( - hg.BUTTON( - hg.DIV( - hg.If( - hg.C("action.icon"), - Icon(hg.C("action.icon"), size=16), - ), - hg.C("action.label"), - _class="bx--overflow-menu-options__option-content", - ), - _class="bx--overflow-menu-options__btn", - role="menuitem", - type="button", - title=hg.C("action.label"), - onclick=hg.C("action.js"), - ), + hg.F(asoverflowbutton), **item_attributes, ), ), diff --git a/bread/layout/components/pagination.py b/bread/layout/components/pagination.py index 36507cf8..88b002df 100644 --- a/bread/layout/components/pagination.py +++ b/bread/layout/components/pagination.py @@ -1,4 +1,8 @@ +from typing import Iterator, NamedTuple + import htmlgenerator as hg +from django.conf import settings +from django.core.paginator import Paginator from django.utils.translation import gettext_lazy as _ from bread.utils.urls import link_with_urlparameters @@ -8,6 +12,19 @@ from .select import Select +class PaginationConfig(NamedTuple): + paginator: Paginator + items_per_page_options: Iterator = getattr( + settings, "DEFAULT_PAGINATION_CHOICES", [25, 50, 100] + ) + page_urlparameter: str = ( + "page" # URL parameter which holds value for current page selection + ) + itemsperpage_urlparameter: str = ( + "itemsperpage" # URL parameter which selects items per page + ) + + class Pagination(hg.DIV): def __init__( self, @@ -170,6 +187,15 @@ def __init__( **kwargs, ) + @classmethod + def from_config(cls, pagination_config): + return cls( + paginator=pagination_config.paginator, + items_per_page_options=pagination_config.items_per_page_options, + page_urlparameter=pagination_config.page_urlparameter, + itemsperpage_urlparameter=pagination_config.itemsperpage_urlparameter, + ) + def linktopage(page_urlparameter, page): return hg.F( diff --git a/bread/layout/components/search.py b/bread/layout/components/search.py index 3a2d6899..b9bdbb43 100644 --- a/bread/layout/components/search.py +++ b/bread/layout/components/search.py @@ -8,6 +8,8 @@ class SearchBackendConfig(typing.NamedTuple): + """Describes an endpoint for auto-complete searches""" + url: typing.Any result_selector: str = None result_label_selector: str = None diff --git a/bread/utils/links.py b/bread/utils/links.py index add6be82..bdf811c6 100644 --- a/bread/utils/links.py +++ b/bread/utils/links.py @@ -9,8 +9,8 @@ class LazyHref(hg.Lazy): """An element which will resolve lazy. The ``args`` and ``kwargs`` arguments will - be passed to ``bread.utils.urls.reverse``. Every item in ``args`` will be resolved - and every value in ``kwargs`` will be resolved. + be passed to ``bread.utils.urls.reverse``. Every (lazy) item in ``args`` will be + resolved and every value in ``kwargs`` will be resolved. Example usage: @@ -59,7 +59,13 @@ def __init__(self, model, name, *args, **kwargs): if "kwargs" not in kwargs: kwargs["kwargs"] = {} kwargs["kwargs"]["pk"] = model.pk - super().__init__(model_urlname(model, name), *args, **kwargs) + + if isinstance(model, hg.Lazy): + url = hg.F(lambda c: model_urlname(hg.resolve_lazy(model, c), name)) + else: + url = model_urlname(model, name) + + super().__init__(url, *args, **kwargs) def try_call(var, *args, **kwargs): @@ -71,6 +77,7 @@ class Link(NamedTuple): label: str iconname: str = "fade" permissions: List[str] = [] + attributes: dict = {} def has_permission(self, request, obj=None): return all( diff --git a/bread/views/browse.py b/bread/views/browse.py index 69e4cd27..059a90ce 100644 --- a/bread/views/browse.py +++ b/bread/views/browse.py @@ -16,12 +16,14 @@ from .. import layout as _layout # prevent name clashing from ..utils import ( + Link, + ModelHref, generate_excel, + get_concrete_instance, link_with_urlparameters, pretty_modelname, xlsxresponse, ) -from ..utils.links import Link from .util import BreadView @@ -45,14 +47,13 @@ class BrowseView(BreadView, LoginRequiredMixin, PermissionListMixin, ListView): """TODO: documentation""" orderingurlparameter = "ordering" - itemsperpage_urlparameter = "itemsperpage" objectids_urlparameter = "_selected" # see bread/static/js/main.js:submitbulkaction and bread/layout/components/datatable.py bulkaction_urlparameter = "_bulkaction" - pagination_choices = () + items_per_page_options = None + itemsperpage_urlparameter = "itemsperpage" columns = ["__all__"] - searchurl = None - query_urlparameter = "q" - rowclickaction = None + search_backend = None + rowclickaction: Optional[Link] = None # bulkactions: List[(Link, function(request, queryset))] # - link.js should be a slug and not a URL # - if the function returns a HttpResponse, the response is returned instead of the browse view result @@ -74,19 +75,16 @@ def __init__(self, *args, **kwargs): self.bulkaction_urlparameter = ( kwargs.get("bulkaction_urlparameter") or self.bulkaction_urlparameter ) - self.pagination_choices = ( - kwargs.get("pagination_choices") - or self.pagination_choices + self.items_per_page_options = ( + kwargs.get("items_per_page_options") + or self.items_per_page_options or getattr(settings, "DEFAULT_PAGINATION_CHOICES", [25, 100, 500]) ) self.rowactions = kwargs.get("rowactions") or self.rowactions self.columns = expand_ALL_constant( kwargs["model"], kwargs.get("columns") or self.columns ) - self.searchurl = kwargs.get("searchurl") or self.searchurl - self.query_urlparameter = ( - kwargs.get("query_urlparameter") or self.query_urlparameter - ) + self.search_backend = kwargs.get("search_backend") or self.search_backend self.rowclickaction = kwargs.get("rowclickaction") or self.rowclickaction self.backurl = kwargs.get("backurl") or self.backurl self.primary_button = kwargs.get("primary_button") or self.primary_button @@ -130,13 +128,16 @@ def get_layout(self): columns=self.columns, bulkactions=bulkactions, rowactions=self.rowactions, - searchurl=self.searchurl, - query_urlparameter=self.query_urlparameter, + rowactions_dropdown=len(self.rowactions) + > 2, # recommendation from carbon design + search_backend=self.search_backend, rowclickaction=self.rowclickaction, - pagination_options=self.pagination_choices, - page_urlparameter=self.page_kwarg, - paginator=self.get_paginator(qs, self.get_paginate_by(qs)), - itemsperpage_urlparameter=self.itemsperpage_urlparameter, + pagination_config=_layout.pagination.PaginationConfig( + items_per_page_options=self.items_per_page_options, + page_urlparameter=self.page_kwarg, + paginator=self.get_paginator(qs, self.get_paginate_by(qs)), + itemsperpage_urlparameter=self.itemsperpage_urlparameter, + ), checkbox_for_bulkaction_name=self.objectids_urlparameter, settingspanel=self.get_settingspanel(), backurl=self.backurl, @@ -185,17 +186,22 @@ def get(self, *args, **kwargs): def get_paginate_by(self, queryset): return self.request.GET.get( - self.itemsperpage_urlparameter, self.pagination_choices[0] + self.itemsperpage_urlparameter, self.items_per_page_options[0] ) def get_queryset(self): """Prefetch related tables to speed up queries. Also order result by get-parameters.""" qs = super().get_queryset() - if self.query_urlparameter in self.request.GET: + if ( + self.search_backend + and self.search_backend.query_parameter in self.request.GET + ): qs = apply_search( qs, "(" - + ") and (".join(self.request.GET.getlist(self.query_urlparameter)) + + ") and (".join( + self.request.GET.getlist(self.search_backend.query_parameter) + ) + ")", ) @@ -220,6 +226,23 @@ def get_queryset(self): ) return qs + @staticmethod + def gen_rowclickaction(modelaction): + """ + Shortcut to get a Link to a model view. + The default models views in bread are "read", "edit", "delete". + :param modelaction: A model view whose name has been generated with ``bread.utils.urls.model_urlname`` + """ + return Link( + label="", + href=ModelHref( + hg.F(lambda c: get_concrete_instance(c["row"])), + modelaction, + kwargs={"pk": hg.C("row.pk")}, + ), + iconname=None, + ) + # helper function to export a queryset to excel def export(queryset, columns):