Skip to content

Commit

Permalink
✨ make breadcrumbs more flexible and typesafe in views
Browse files Browse the repository at this point in the history
  • Loading branch information
krmax44 committed Jul 15, 2024
1 parent 1f51dfd commit a8423fe
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 48 deletions.
118 changes: 118 additions & 0 deletions froide/helper/breadcrumbs.py
Original file line number Diff line number Diff line change
@@ -0,0 1,118 @@
from __future__ import annotations

import logging
from collections.abc import Generator, Sequence
from dataclasses import dataclass
from typing import Optional, Tuple, Union

from django.template.context import Context
from django.urls import NoReverseMatch, reverse
from django.views import View

logger = logging.getLogger(__name__)

BreadcrumbTuple = Tuple[str, Union[str, None]]
"""
First item: link text
Second item: link, or None
"""

BreadcrumbItems = Sequence[Union[str, BreadcrumbTuple]]


class BreadcrumbView(View):
"""
Your view must either provide a `breadcrumbs` attribute, or a
`get_breadcrumbs` method.
The `breadcrumbs` attribute is a sequence of strings or tuples,
e.g. ["Just text", ("A link", "url-pattern-name")].
The first element of the tuple specifies the link text, the second one
will be REVERSED to the link, so you can provide path names.
Using the `get_breadcrumbs` method, you can provide breadcrumbs as
above, with the difference that urls WILL NOT BE REVERSED.
You can also directly return a `Breadcrumbs` instance.
Example implementation:
class MyView(BreadcrumbView):
def get_breadcrumbs():
return [("Foo", "/foo/"), "Bar"]
"""

breadcrumbs: Optional[Sequence]

def get_breadcrumbs(self) -> Union[BreadcrumbItems, Breadcrumbs]:
raise NotImplementedError("No breadcrumb provider implemented")


@dataclass
class BreadcrumbItem:
# will be displayed as the link text
title: str
url: Optional[str] = None
# whether the breadcrumbs should overlay the following content
overlay: Optional[bool] = False

@property
def has_link(self) -> bool:
return bool(self.url)


@dataclass
class Breadcrumbs:
items: BreadcrumbItems
color: Optional[str] = None

def __iter__(self) -> Generator[BreadcrumbItem]:
for item in self.items:
if type(item) is str:
yield BreadcrumbItem(title=item)
elif type(item) is tuple and len(item) == 2:
yield BreadcrumbItem(title=item[0], url=item[1])

def __add__(self, items):
"""
Add items using the plus operator: breadcrumbs [...]
"""
self.items = items
return self

@staticmethod
def from_view(
view: BreadcrumbView, context: Union[Context, dict[str, object]]
) -> Optional[Breadcrumbs]:
if hasattr(view, "get_breadcrumbs") and callable(view.get_breadcrumbs):
value = view.get_breadcrumbs(context)

if isinstance(value, Breadcrumbs):
return value

items = map(normalize_breadcrumb, value)
return Breadcrumbs(items=items)

if hasattr(view, "breadcrumbs"):
items = map(normalize_breadcrumb, view.breadcrumbs)
items = map(reverse_breadcrumb, items)
return Breadcrumbs(items=items)


def normalize_breadcrumb(breadcrumb: Union[str, BreadcrumbTuple]):
if type(breadcrumb) is tuple:
return breadcrumb
elif type(breadcrumb) is str:
return (breadcrumb, None)

logger.error("Received breadcrumb that is neither a tuple nor a string", breadcrumb)


def reverse_breadcrumb(breadcrumb: BreadcrumbTuple) -> BreadcrumbTuple:
if type(breadcrumb[1]) is str:
try:
return (breadcrumb[0], reverse(breadcrumb[1]))
except NoReverseMatch:
logger.error("Breadcrumb url could not be reversed", breadcrumb)
return (breadcrumb[0], None)

return breadcrumb
27 changes: 3 additions & 24 deletions froide/helper/templatetags/breadcrumb_helper.py
Original file line number Diff line number Diff line change
@@ -1,31 1,10 @@
from django import template
from django.urls import NoReverseMatch, reverse

register = template.Library()

from froide.helper.breadcrumbs import Breadcrumbs

def normalize_breadcrumb(breadcrumb):
if type(breadcrumb) == tuple:
if type(breadcrumb[1]) == str:
try:
breadcrumb = (breadcrumb[0], reverse(breadcrumb[1]))
except NoReverseMatch:
pass

return breadcrumb
else:
return (breadcrumb, None)
register = template.Library()


@register.simple_tag(takes_context=True)
def get_breadcrumbs(context, view=None):
if hasattr(view, "get_breadcrumbs") and callable(view.get_breadcrumbs):
return view.get_breadcrumbs(context)

if hasattr(view, "breadcrumbs"):
return map(normalize_breadcrumb, view.breadcrumbs)


@register.filter
def has_link(value):
return type(value) == tuple and len(value) == 2
return Breadcrumbs.from_view(view, context)
50 changes: 26 additions & 24 deletions froide/templates/snippets/breadcrumbs.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 3,31 @@
{# Renders breadcrumbs that are defined in view #}
{% get_breadcrumbs view as breadcrumbs %}
{% if breadcrumbs != None %}
{% if breadcrumbs_background %}
<div class="text-bg-{{ breadcrumbs_background }}{% if overlay %} breadcrumb-overlay{% endif %}">
{% endif %}
<nav class="container-md" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
{# djlint:off D018 #}
<a href="/"> {# djlint:on #}
<i class="fa fa-home"></i>
<span class="sr-only">{% trans "Home Page" %}</span>
</a>
</li>
{% for breadcrumb in breadcrumbs %}
<li class="breadcrumb-item{% if forloop.last %} active{% endif %}">
{% if breadcrumb|has_link %}
<a href="{{ breadcrumb.1 }}"
{% if forloop.last %}aria-current="page"{% endif %}>{{ breadcrumb.0 }}</a>
{% else %}
{{ breadcrumb }}
{% endif %}
{% with breadcrumbs_background=breadcrumbs_background|default:breadcrumbs.color %}
{% if breadcrumbs_background %}
<div class="text-bg-{{ breadcrumbs_background }}{% if overlay|default:breadcrumbs.overlay %} breadcrumb-overlay{% endif %}">
{% endif %}
<nav class="container-md" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
{# djlint:off D018 #}
<a href="/"> {# djlint:on #}
<i class="fa fa-home"></i>
<span class="sr-only">{% trans "Home Page" %}</span>
</a>
</li>
{% endfor %}
</ol>
</nav>
{% if breadcrumbs_background %}</div>{% endif %}
{% for breadcrumb in breadcrumbs %}
<li class="breadcrumb-item{% if forloop.last %} active{% endif %}">
{% if breadcrumb.has_link %}
<a href="{{ breadcrumb.url }}"
{% if forloop.last %}aria-current="page"{% endif %}>{{ breadcrumb.title }}</a>
{% else %}
{{ breadcrumb.title }}
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% if breadcrumbs_background %}</div>{% endif %}
{% endwith %}
{% endif %}

0 comments on commit a8423fe

Please sign in to comment.